From b4f7b26f4178212df8ab4140de8220f7cb4bc433 Mon Sep 17 00:00:00 2001 From: tim-eternos Date: Wed, 12 Aug 2026 12:04:27 -0700 Subject: [PATCH 1/3] ci: automated deploy to GCP VM on push to main - deploy.yml: WIF auth (no keys), build+push image to Artifact Registry, ship deploy files and run deploy.sh on planner-vm over an IAP tunnel. workflow_dispatch with a `tag` input redeploys/rolls back an existing image without rebuilding. Image tag validated against the docker-tag charset before entering any shell command. - go.yml: workflow_call trigger so deploy gates on lint + build + test. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016aitZGthghNeJEdjYFsZT8 --- .github/workflows/deploy.yml | 71 ++++++++++++++++++++++++++++++++++++ .github/workflows/go.yml | 1 + 2 files changed, 72 insertions(+) create mode 100644 .github/workflows/deploy.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..c059afe8 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,71 @@ +name: Deploy + +on: + push: + branches: [main] + workflow_dispatch: + inputs: + tag: + description: "Existing image tag to (re)deploy; empty = build current SHA" + required: false + +permissions: + contents: read + id-token: write + +concurrency: + group: deploy-production + cancel-in-progress: false + +jobs: + test: + uses: ./.github/workflows/go.yml + + deploy: + needs: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Resolve and validate image tag + id: tag + run: | + tag="${{ inputs.tag != '' && inputs.tag || github.sha }}" + # tag flows into shell commands below and a docker reference; allow + # only the characters valid in a docker tag so it cannot inject. + if ! printf '%s' "$tag" | grep -Eq '^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$'; then + echo "refusing unsafe image tag: $tag" >&2 + exit 1 + fi + echo "tag=$tag" >> "$GITHUB_OUTPUT" + + - uses: google-github-actions/auth@v3 + with: + workload_identity_provider: ${{ vars.GCP_WIF_PROVIDER }} + service_account: ${{ vars.GCP_DEPLOYER_SA }} + + - uses: google-github-actions/setup-gcloud@v3 + + - name: Build and push image + if: inputs.tag == '' + env: + IMAGE: ${{ vars.GCP_IMAGE }} + TAG: ${{ steps.tag.outputs.tag }} + run: | + gcloud auth configure-docker us-west1-docker.pkg.dev --quiet + docker build -t "${IMAGE}:${TAG}" . + docker push "${IMAGE}:${TAG}" + + - name: Deploy to VM over IAP + env: + ZONE: ${{ vars.GCP_ZONE }} + VM: ${{ vars.GCP_VM }} + TAG: ${{ steps.tag.outputs.tag }} + run: | + gcloud compute ssh "$VM" --zone "$ZONE" --tunnel-through-iap \ + --command='mkdir -p /tmp/planner-deploy' + gcloud compute scp deploy/docker-compose.prod.yml deploy/Caddyfile \ + deploy/env.production deploy/deploy.sh \ + "$VM":/tmp/planner-deploy/ --zone "$ZONE" --tunnel-through-iap + gcloud compute ssh "$VM" --zone "$ZONE" --tunnel-through-iap \ + --command="sudo bash -c 'cp /tmp/planner-deploy/* /opt/planner/ && bash /opt/planner/deploy.sh ${TAG}'" diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index eb357661..838b5d3a 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -5,6 +5,7 @@ on: branches: [main] pull_request: branches: [main] + workflow_call: jobs: golangci: From 979210134f91d277caf3dbaf677c6211ba562b6b Mon Sep 17 00:00:00 2001 From: tim-eternos Date: Wed, 12 Aug 2026 12:10:08 -0700 Subject: [PATCH 2/3] ci: harden deploy workflow per review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - route inputs.tag through env (RAW_TAG) instead of interpolating it into the run script — a $(cmd) value would otherwise execute before the tag regex validated it - grant id-token: write only to the deploy job; the reusable test job now inherits a read-only token Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016aitZGthghNeJEdjYFsZT8 --- .github/workflows/deploy.yml | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c059afe8..9c11166a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -9,9 +9,9 @@ on: description: "Existing image tag to (re)deploy; empty = build current SHA" required: false +# default token is read-only; only the deploy job is granted id-token for WIF permissions: contents: read - id-token: write concurrency: group: deploy-production @@ -24,13 +24,22 @@ jobs: deploy: needs: test runs-on: ubuntu-latest + permissions: + contents: read + id-token: write steps: - uses: actions/checkout@v6 - name: Resolve and validate image tag id: tag + env: + # pass untrusted input through the environment, never interpolate it + # into the script body — a value like $(cmd) would otherwise run + # before the regex check below + RAW_TAG: ${{ inputs.tag }} + SHA: ${{ github.sha }} run: | - tag="${{ inputs.tag != '' && inputs.tag || github.sha }}" + tag="${RAW_TAG:-$SHA}" # tag flows into shell commands below and a docker reference; allow # only the characters valid in a docker tag so it cannot inject. if ! printf '%s' "$tag" | grep -Eq '^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$'; then From 9ce954c86534f001b87e524de481ebdb80c4e6e5 Mon Sep 17 00:00:00 2001 From: tim-eternos Date: Wed, 12 Aug 2026 12:10:58 -0700 Subject: [PATCH 3/3] ci: disable checkout credential persistence in deploy workflow Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016aitZGthghNeJEdjYFsZT8 --- .github/workflows/deploy.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 9c11166a..727cb62d 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -29,6 +29,9 @@ jobs: id-token: write steps: - uses: actions/checkout@v6 + with: + # the workflow never pushes git; don't leave the token in .git/config + persist-credentials: false - name: Resolve and validate image tag id: tag