Skip to content

sec(ci): move the remaining run-block ${{ }} interpolations into env (#155) - #1147

Merged
IAmJSD merged 9 commits into
LeanerCloud:mainfrom
IAmJSD:fix/issue-155
Oct 25, 2026
Merged

IAmJSD merged 9 commits into
LeanerCloud:mainfrom
IAmJSD:fix/issue-155

Conversation

@IAmJSD

@IAmJSD IAmJSD commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator

What

Finishes the run-block sweep from #155. On current main there were 55 ${{ }} interpolation lines left inside run: blocks across 5 workflow files (the issue's table of 159 is out of date: deploy-aws-lambda.yml, database-migration.yml and part of the deploy-* files have been cleaned up on main since it was written). This PR moves every one of them into env: and references the shell variable quoted. Afterwards the issue's parser reports 0, and zizmor's template-injection audit goes from 58 findings to 0.

file before after
deploy-all.yml 25 0
deploy-azure.yml 11 0
deploy-gcp.yml 8 0
deploy-aws-fargate.yml 6 0
ci.yml 5 0
total 55 0

There's one commit per workflow file, so each can be reviewed by inspection. #155 suggested one PR per file. I'm happy to split this into five PRs if you'd prefer that.

How (mechanical, no logic changes)

  • Each value goes into an env: entry with the original expression copied exactly, including ${{ inputs.deploy_to || 'all' }}, so fallbacks and empty values evaluate the same way as before.
  • Step-level env: is used everywhere except in two places that use job-level env::
  • Existing shell assignments are kept and now read from the env var. For example, ENVIRONMENT="${{ needs.prepare.outputs.target_environment }}" becomes ENVIRONMENT="$TARGET_ENVIRONMENT". The rest of each script is byte-identical, and the variable stays unexported, just as it was.
  • ${{ env.AZURE_LOCATION }} and ${{ env.GCP_REGION }} become ${AZURE_LOCATION} and $GCP_REGION. Both are workflow-level env: entries that the runner already exports to every step, and nothing writes them to $GITHUB_ENV.
  • New env names don't collide with anything the steps already use or with variables that tools read. I checked each job's existing env: and scripts/scan-shipped-image.sh, which reads no IMAGE variable.
  • No set -euo pipefail was added, per the issue's 2026-08-03 note.

Deliberate quoting change (2 sites): scripts/scan-shipped-image.sh cudly:${{ github.sha }} and cd terraform/environments/${{ matrix.cloud }} were unquoted, and they're now "$IMAGE" and "terraform/environments/$CLOUD". The result only differs for a value containing whitespace, glob or shell metacharacters. github.sha is always 40 hex characters and matrix.cloud is the fixed list [aws, gcp, azure], so neither can reach that case.

ci.yml's workflow-lint comment cited "106 findings … 66 template-injection" below the zizmor threshold. It now says the pinned zizmor 1.29.0 reports 35 low findings (24 undocumented-permissions, 11 concurrency-limits), that zizmor 1.31 adds 4 low self-repository findings on deploy-all.yml's local reusable-workflow calls, and that template-injection is at zero (separate commits).

Verification

All of this was run locally on macOS. No workflow was run, so the behavioral evidence below comes from a local simulation of the runner, not from Actions.

  1. Census (the issue's parser, unchanged): origin/main reports TOTAL 55, HEAD reports TOTAL 0. The origin/main run doubles as the negative control: the same parser finds the 55 hits on the pre-fix files.
  2. zizmor 1.29.0 (the version CI pins):
    • zizmor --offline --persona=pedantic --min-severity=medium .github/workflows/ (the CI gate): "No findings" both before and after. Ignored findings drop from 93 to 35.
    • --format=json at --persona=pedantic: template-injection 58 → 0. At --persona=auditor: 0.
    • The 35 findings left are all low: undocumented-permissions and concurrency-limits. They're out of scope here.
  3. actionlint 1.7.12 with shellcheck 0.11.0 and pyflakes 3.4.0 (same versions as the pre-commit hook): exit 0 on both origin/main and HEAD.
  4. Before/after equivalence harness (fixture-level simulation, script below):
    • Setup: for every run: step that had an interpolation (19 steps), the harness renders the origin/main version by splicing values into the script text, as the runner does. It runs the HEAD version with the values passed through workflow, job and step env:. Both run under bash --noprofile --norc -e with stubbed terraform, az, bash and sleep.
    • Compared: exit code, stdout, stderr, $GITHUB_OUTPUT, $GITHUB_STEP_SUMMARY and the exact argv of each stubbed command.
    • Value scenarios: benign values; mixed results (failure, skipped, cancelled, empty deploy_to, empty vars.*); all-empty; a value unique to each expression (catches swapped env names); and multi-line values.
    • Result: 93 of 95 runs identical. The 2 differences are the deliberate github.sha quoting change above, triggered by the multi-line and metacharacter scenarios that github.sha can't produce.
    • Fail-before/pass-after: with $(touch PWNED_S) and a quote-breaking payload, the pre-fix scripts executed the payload in 28 runs. The post-fix scripts executed it in 0 of 30. The 4 matrix.cloud steps were not given payloads because the matrix is a static list.
    • Mutation probes: each of these deliberate breakages in the new files was detected as a difference: swapping two result variables, misspelling AZURE_LOCATION, wiring project_id to the wrong variable, hard-coding the cloud, dropping || 'all', and changing the IMAGE / IMAGE_TAG source.
  5. git-secrets (--register-aws, repo .gitallowed) on the changed files in a fresh temporary repo: clean. trailing-whitespace and end-of-file checks: clean.
Equivalence harness (run: python3 equiv.py <repo> origin/main .github/workflows/{ci,deploy-all,deploy-aws-fargate,deploy-azure,deploy-gcp}.yml, needs PyYAML)
"""Render each changed run: step the way the Actions runner would, before
(origin/main: ${{ }} spliced into script text) and after (values passed via
env:), execute both under `bash -e` with stubbed external commands, and
compare everything observable."""
import os, re, subprocess, sys, tempfile, yaml, shutil

REPO = sys.argv[1]
BASE = sys.argv[2]
FILES = sys.argv[3:]
EXPR = re.compile(r"\$\{\{\s*(.*?)\s*\}\}")

PRELUDE = r'''
__log() { local a; { printf '%s' "$1"; shift; for a in "$@"; do printf ' %q' "$a"; done; printf '\n'; } >> "$STUBLOG"; }
terraform() { __log terraform "$@"; }
az() { __log az "$@"; echo stubval; }
bash() { __log bash "$@"; }
sleep() { __log sleep "$@"; }
date() { echo 2026-01-01T00:00:00Z; }
'''

def benign(path):
    if path.endswith(".result"): return "success"
    if "outputs.deploy-" in path: return "true"
    if path.endswith("environment") or path.endswith("target_environment"): return "dev"
    if path == "github.sha": return "0123456789abcdef0123456789abcdef01234567"
    if path == "matrix.cloud": return "aws"
    if path.endswith("app_url") or path.endswith("service_url"): return "https://app.example.com/"
    if path == "inputs.deploy_to": return "aws-gcp"
    if path.startswith("vars."): return "proj-123"
    return "val"

SCENARIOS = {
    "benign": benign,
    "mixed": lambda p: {"needs.deploy-gcp.result": "failure", "needs.deploy-azure.result": "skipped",
                        "needs.determine-deployment.outputs.deploy-aws-fargate": "false",
                        "needs.test-deployment.result": "cancelled", "inputs.deploy_to": "",
                        "vars.AZURE_LOCATION": "", "vars.GCP_PROJECT_ID": "", "matrix.cloud": "gcp"}.get(p, benign(p)),
    "empty": lambda p: "",
    # every expression gets a value unique to it, so cross-wired env names show up
    "distinct": lambda p: "aws" if p == "matrix.cloud" else "v<" + p + ">",
    "multiline": lambda p: "first line\nsecond line" if p != "matrix.cloud" else "aws",
    "payload_subst": lambda p: "$(touch PWNED_S)" if p != "matrix.cloud" else "aws",
    "payload": lambda p: '$(touch PWNED)`touch PWNED2`"; touch PWNED3; echo "' if p != "matrix.cloud" else "aws",
}

def evaluate(expr, val):
    for alt in [a.strip() for a in expr.split("||")]:
        m = re.fullmatch(r"'(.*)'", alt)
        v = m.group(1) if m else val(alt)
        if v != "": return v
    return ""

def render(template, val, wfenv):
    def sub(m):
        e = m.group(1)
        if e.startswith("env."): return wfenv.get(e[4:], "")
        if e.startswith("secrets."): return "secret-value"
        return evaluate(e, val)
    return EXPR.sub(sub, str(template))

def run(script, env, workdir):
    for d in ("terraform/environments/aws/ci-cd-permissions/tests", "terraform/environments/gcp", "terraform/environments/azure"):
        os.makedirs(os.path.join(workdir, d), exist_ok=True)
    open(os.path.join(workdir, "terraform/environments/aws/github-dev.tfvars"), "w").close()
    out_f, sum_f, log_f = (os.path.join(workdir, n) for n in ("out", "summary", "stublog"))
    for f in (out_f, sum_f, log_f): open(f, "w").close()
    full = dict(PATH=os.environ["PATH"], HOME=workdir, GITHUB_OUTPUT=out_f, GITHUB_STEP_SUMMARY=sum_f,
                STUBLOG=log_f, RUNNER_TEMP=workdir, **env)
    sp = os.path.join(workdir, "step.sh")
    open(sp, "w").write(PRELUDE + script)
    p = subprocess.run(["/bin/bash", "--noprofile", "--norc", "-e", sp], cwd=workdir, env=full,
                       capture_output=True, text=True, timeout=60)
    created = sorted(f for _, _, fs in os.walk(workdir) for f in fs if f.startswith("PWNED"))
    res = dict(rc=p.returncode, stdout=p.stdout, stderr=p.stderr.replace(workdir, "<W>"),
               output=open(out_f).read(), summary=open(sum_f).read(),
               stub=open(log_f).read().replace(workdir, "<W>"), pwned=created)
    return res

def steps(doc):
    for jn, job in doc["jobs"].items():
        for i, st in enumerate(job.get("steps") or []):
            if "run" in st: yield jn, i, job, st

total = diffs = fixed = 0
for f in FILES:
    old = yaml.safe_load(subprocess.check_output(["git", "-C", REPO, "show", f"{BASE}:{f}"], text=True))
    new = yaml.safe_load(open(os.path.join(REPO, f)))
    newsteps = {(jn, i): (job, st) for jn, i, job, st in steps(new)}
    for jn, i, ojob, ost in steps(old):
        if not EXPR.search(ost["run"]): continue
        njob, nst = newsteps[(jn, i)]
        assert nst.get("name") == ost.get("name"), (f, jn, i)
        assert not EXPR.search(nst["run"]), (f, jn, i)
        total += 1
        for sname, val in SCENARIOS.items():
            wfenv_o = {k: render(v, val, {}) for k, v in (old.get("env") or {}).items()}
            wfenv_n = {k: render(v, val, {}) for k, v in (new.get("env") or {}).items()}
            assert wfenv_o == wfenv_n
            # old: workflow+job+step env as-is, ${{ }} spliced into the script text
            env_o = dict(wfenv_o)
            for layer in (ojob.get("env") or {}, ost.get("env") or {}):
                env_o.update({k: render(v, val, wfenv_o) for k, v in layer.items()})
            script_o = render(ost["run"], val, wfenv_o)
            env_n = dict(wfenv_n)
            for layer in (njob.get("env") or {}, nst.get("env") or {}):
                env_n.update({k: render(v, val, wfenv_n) for k, v in layer.items()})
            script_n = nst["run"]
            with tempfile.TemporaryDirectory() as a, tempfile.TemporaryDirectory() as b:
                ro, rn = run(script_o, env_o, a), run(script_n, env_n, b)
            tag = f"{f} :: {jn} :: {ost.get('name')} :: {sname}"
            if sname.startswith("payload"):
                print(f"{tag}: before pwned={ro['pwned']} rc={ro['rc']} | after pwned={rn['pwned']} rc={rn['rc']}")
                if ro["pwned"] and not rn["pwned"]: fixed += 1
                if rn["pwned"]: diffs += 1; print("  !! AFTER STILL EXECUTES PAYLOAD")
                continue
            keys = [k for k in ro if ro[k] != rn[k]]
            if keys:
                diffs += 1
                print(f"DIFF {tag}: {keys}")
                for k in keys: print(f"   before {k}={ro[k]!r}\n   after  {k}={rn[k]!r}")
            else:
                print(f"same {tag} (rc={ro['rc']})")
print(f"\nsteps checked: {total}; behavior diffs on non-payload scenarios: {diffs}; payload executed before but not after: {fixed}")

Overlap with open PRs

I tested each of these with git merge-tree against this branch's head:

Out of scope

  • A CI step that runs the census parser and fails on any hit. The issue suggests making that a follow-up issue. Today the zizmor gate doesn't catch new template-injection findings, because they score low or informational and the gate's threshold is --min-severity=medium.
  • Adding set -euo pipefail (a separate pass, per the issue).
  • The remaining low zizmor findings (undocumented-permissions, concurrency-limits).

Closes #155

Could a maintainer please mirror the issue's triage labels onto this PR: priority/p2, severity/medium, impact/internal, type/chore, triaged, urgency/this-quarter, effort/m.

🤖 Generated with Claude Code

IAmJSD and others added 6 commits October 19, 2026 23:46
Moves the five remaining ${{ }} interpolations out of ci.yml run:
blocks: github.sha in the image scan step goes through a step-level
IMAGE env (same name LeanerCloud#1139 uses for that image ref), and matrix.cloud in
terraform-validate goes through a job-level CLOUD env, now quoted at
each use. No other change to the steps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d#155)

Moves the 25 remaining ${{ }} interpolations (inputs.deploy_to, step
outputs, needs.*.outputs and needs.*.result) out of deploy-all.yml run:
blocks into step-level env for determine-deployment and a job-level env
for notify, referenced quoted. Each env entry keeps the original
expression verbatim, including the `|| 'all'` fallback. No logic change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…anerCloud#155)

Moves the six remaining ${{ }} interpolations (github.sha in Set image
tag, and the prepare output and needs.*.result values in Post summary)
into step-level env, referenced quoted. No logic change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oud#155)

Moves the eleven remaining ${{ }} interpolation lines out of
deploy-azure.yml run: blocks. needs.* values go through step-level env;
${{ env.AZURE_LOCATION }} becomes ${AZURE_LOCATION}, the workflow-level
env var the runner already exports to every step. The existing
`ENVIRONMENT=` and `APP_URL=` shell assignments are kept, reading from
the new env vars, so the rest of each script is untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d#155)

Moves the eight remaining ${{ }} interpolation lines out of
deploy-gcp.yml run: blocks. needs.* and vars.GCP_PROJECT_ID go through
step-level env; ${{ env.GCP_REGION }} becomes $GCP_REGION, the
workflow-level env var the runner already exports. The SERVICE_URL=
assignment is kept, reading from the new env var.

Closes LeanerCloud#155

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oud#155)

The workflow-lint comment cited 106 findings including 66
template-injection. With the run-block sweep done, pedantic zizmor
reports 35 low findings and no template-injection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

This review includes 5 billable files and costs up to $1.25.

Or wait 59 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

Run configuration
Commits

Reviewing files that changed from the base of the PR and between 1c5787a and a20cf84.

Files selected for processing: 5
  • .github/workflows/ci.yml
  • .github/workflows/deploy-all.yml
  • .github/workflows/deploy-aws-fargate.yml
  • .github/workflows/deploy-azure.yml
  • .github/workflows/deploy-gcp.yml
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

IAmJSD added a commit to IAmJSD/cloud-commitments-platform that referenced this pull request Oct 19, 2026
…ation

Keeps the new step clear of the ${{ }}-in-run pattern that LeanerCloud#155 / LeanerCloud#1147
removes from the workflows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IAmJSD IAmJSD added priority/p2 Backlog-worthy severity/medium Moderate harm impact/internal Team-internal only type/chore Maintenance / non-user-visible triaged Item has been triaged urgency/this-quarter Within the quarter effort/m Days labels Oct 19, 2026
@cristim

cristim commented Oct 20, 2026

Copy link
Copy Markdown
Member

Independent review (claude-cc-platform reviewer, cold brief): REQUEST_CHANGES 0d7c77f67cb48b6a27793f6f1bfdd568dbeaca9e

@IAmJSD the conversion itself is correct everywhere I checked. The only blocker is a rebase conflict with main.

  1. Blocking: .github/workflows/deploy-all.yml, the Summarize / FAILED-check step (main ~L315-335). git merge-tree origin/main conflicts here. Main's fix: six small scheduled-auth, CI, OpenAPI and IaC fixes #1127 (bb2d2312) replaced the denylist with a check_job allowlist and added 5 new ${{ }} lines in that run block:

    • check_job "${{ needs.determine-deployment.outputs.deploy-aws-lambda }}" "${{ needs.deploy-aws-lambda.result }}", plus the fargate, gcp and azure variants;
    • if [[ "${{ needs.determine-deployment.result }}" != "success" ]].

    After the rebase, convert them to check_job "$DEPLOY_AWS_LAMBDA" "$DEPLOY_AWS_LAMBDA_RESULT" (and so on) and "$DETERMINE_DEPLOYMENT_RESULT"; your job-level env already defines all of these. Taking main's side would bring the run-block census back from 0 to 5. Please re-run the parser and actionlint afterwards.

  2. Nit, ci.yml ~L240: the comment says "35 findings ... undocumented-permissions and concurrency-limits". zizmor on the head reports 39 non-suppressed low findings: 24 undocumented-permissions, 11 concurrency-limits and 4 self-repository. The sentence omits self-repository.

Verified:

  • Census: the issue's jobs.*.steps[].run walk finds 0 hits in all 16 workflow files at the head. As a negative control, database-migration.yml on main has 54 ${{ outside run blocks.
  • Linters: actionlint is clean on all 16 files. zizmor reports no template-injection findings and nothing at medium or above.
  • Expressions: every moved expression is copied verbatim, including both inputs.deploy_to || 'all' sites.
  • Name collisions: none with GITHUB_*, RUNNER_*, AWS_*, ARM_* or GOOGLE_*. GCP_PROJECT_ID is not read by the Google provider.
  • Scope: $GCP_REGION and $AZURE_LOCATION are workflow-level env. CLOUD is job-level, from matrix.cloud. Every other variable is defined in the same step or at job level.
  • Quoting: no heredoc or single-quote sites are touched. The two newly quoted call sites take a SHA and a fixed matrix value.
  • Shell simulations:
    • the payload a b "q" $(touch PWNED) \id` ; x` executed through the old splice path and stays literal through the new env path;
    • a quote-breaking value in [[ "$V" == "all" ]] does not inject;
    • an unset variable under set -u fails loudly, and a manual walk found no reference missing its env entry.

cristim pushed a commit that referenced this pull request Oct 20, 2026
…SQL protection on (#1143)

* fix(iac): lock Azure Postgres against deletion and default GCP Cloud SQL protection on

The three database modules disagreed on deletion protection. The Azure
Flexible Server module had no protection at all, and the GCP module
defaulted deletion_protection to false while AWS defaults it to true.

- modules/database/azure: new deletion_protection input (default true)
  gating a CanNotDelete azurerm_management_lock on the server. The lock
  depends on the server's child and extension resources so Terraform
  removes it first on destroy or replacement; Azure locks are inherited
  and would otherwise fail the destroy with ScopeLocked.
- modules/database/gcp: deletion_protection now defaults to true.
- environments/azure: database_deletion_protection (default true, like
  AWS and GCP); github-dev and github-staging opt out, github-prod sets
  true explicitly.
- ci-cd-permissions (Azure): deploy role gains
  Microsoft.Authorization/locks/{read,write,delete}.
- terraform test suites for both modules (mock providers), wired into
  the CI Validate Terraform job; lock files now carry linux_amd64,
  darwin_arm64 and darwin_amd64 hashes.

Closes #293

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci: read the database module cloud from env, not a run-block interpolation

Keeps the new step clear of the ${{ }}-in-run pattern that #155 / #1147
removes from the workflows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
IAmJSD and others added 2 commits October 21, 2026 08:56
Resolves the conflict in deploy-all.yml's "Check for failures" step with
main's LeanerCloud#1127 check_job allowlist. Main's logic is kept as is; its five
new ${{ }} run-block interpolations now read the notify job's existing
env vars (DEPLOY_<CLOUD>, DEPLOY_<CLOUD>_RESULT and
DETERMINE_DEPLOYMENT_RESULT), so the run-block census stays at 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eanerCloud#155)

The pinned zizmor 1.29.0 reports 35 low findings. zizmor 1.31 adds a
self-repository audit with 4 more on deploy-all.yml's local
reusable-workflow calls, which the comment did not mention.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IAmJSD

IAmJSD commented Oct 21, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks @cristim. Both findings from the review of 0d7c77f6 are addressed. The new head is 76ba95fe8d98a242311aa836e6d08707eb23be13.

  1. [blocking] deploy-all.yml conflict with fix: six small scheduled-auth, CI, OpenAPI and IaC fixes #1127: fixed in merge commit 87a77483. I merged origin/main into the branch (no rebase). I took main's check_job allowlist and its comment exactly as written, and I only changed the 5 new ${{ }} lines to read the notify job-level env: .github/workflows/deploy-all.yml:356-360 (check_job "$DEPLOY_AWS_LAMBDA" "$DEPLOY_AWS_LAMBDA_RESULT", plus the fargate, gcp and azure lines and "$DETERMINE_DEPLOYMENT_RESULT"). There was no other conflict. Main's ALERT_EMAILS lines and its ci.yml and deploy-* changes merged cleanly.
  2. [nit] ci.yml zizmor count: fixed in 76ba95fe at .github/workflows/ci.yml:239-245. The 35 is what CI's pinned zizmor 1.29.0 reports (24 undocumented-permissions and 11 concurrency-limits). Your 39 comes from zizmor 1.31.x: I reproduced it with 1.31.1, and 1.29.0 has no self-repository audit. The comment now gives both numbers and says the 4 self-repository findings are on deploy-all.yml's local reusable-workflow calls.

I also updated the PR body's "Overlap" section. The #1127 note is now past tense. It also says that #1228 removes the deploy-aws-fargate caller job and its check_job line, so whichever of #1147 and #1228 merges second needs a trivial merge in that step.

Verification on 76ba95fe (local, macOS, no workflow run):

  • Census (the issue's jobs.*.steps[].run walk): 0 hits in all workflow files. origin/main c8bdd460 still reports 55, including the 5 check_job/determine-deployment lines.
  • actionlint 1.7.12 (with shellcheck): exit 0.
  • zizmor 1.29.0, CI gate (--offline --persona=pedantic --min-severity=medium): "No findings (35 ignored, 1 suppressed)".
  • zizmor template-injection: 0 at both pedantic and auditor.
  • Simulation of the resolved Check for failures step: I ran main's version (values spliced into the script) and the merged version (values passed through env) under bash -e, across 1,296 combinations of determine-deployment result, per-cloud selection (true/false/empty) and per-cloud result (success/failure/skipped/cancelled/empty). Exit code, stdout, stderr and the step summary were identical in every run. A quote-breaking $(touch PWNED) payload ran on main's version and stayed literal on the merged version. Three mutation probes were each caught: wiring gcp to the azure result, using the lambda result in the determine-deployment check, and passing the lambda selection to the fargate check.
  • Other checks: git-secrets (--register-aws, fresh repo, isolated HOME) was clean on the 5 changed workflows, and git diff --check was clean.
  • Not run: Go build, Go tests and Terraform checks, because this PR touches no Go or Terraform files.

Brings in main's CI fixes (LeanerCloud#1236, LeanerCloud#1252) and CI changes since, so this PR gets a fresh CI run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IAmJSD

IAmJSD commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator Author

Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE a20cf84124366c34a0c90f896b6f4266b00a9021

Blocking:

  • none

Non-blocking:

  • Every changed step except the ci.yml ones runs only on a deploy (deploy-all, deploy-aws-fargate, deploy-azure, deploy-gcp). Merging proves nothing about them, so the first real deploy after merge is the first time this runs on a runner. The change is mechanical and I found no wiring error (see Attacked), but please watch the first dev deploy's summary and plan steps.
  • .github/workflows/deploy-gcp.yml:197: GCP_PROJECT_ID and TARGET_ENVIRONMENT are now exported into the terraform plan process environment. Neither is a name the Google provider or Terraform reads (GOOGLE_PROJECT, GOOGLE_CLOUD_PROJECT, CLOUDSDK_CORE_PROJECT and TF_VAR_* are), so it is harmless. If you want to avoid any future clash, a CUDLY_ prefix would make that explicit.
  • I agree with the follow-up the issue and body suggest: a CI census step that fails on any ${{ in a run: scalar. Without it, fix(iac): lock Azure Postgres against deletion and default GCP Cloud SQL protection on #1143's new terraform/modules/database/${{ matrix.cloud }} line would bring the count back to 1.

Attacked:

  • Census (the issue's YAML parser, all workflow files): origin/main 55, PR head 0, and git merge-tree --write-tree origin/main HEAD (which merges cleanly) also 0.
  • zizmor 1.29.0 --persona=pedantic --format=json: template-injection 62 at the merge base, 0 at the head and 0 on the merged tree. The CI gate (--min-severity=medium) reports no findings. actionlint 1.7.12 with shellcheck exits 0 on the five changed files.
  • I read every hunk by hand and checked the following:
    • Each env value is the original expression verbatim, including || 'all'.
    • Every $VAR maps to the right needs.* or steps.* source.
    • The swapped ${{ env.AZURE_LOCATION }}/${{ env.GCP_REGION }} are workflow-level env (lines 66/58).
    • Job-level env: using matrix/needs is a context GitHub allows there.
    • The check_job lines from fix: six small scheduled-auth, CI, OpenAPI and IaC fixes #1127 are wired to the matching _RESULT names.
  • Fail-before: my own simulation of deploy-all.yml "Set cloud providers" with inputs.deploy_to = '"; touch PWNED; echo "'. The origin/main script executes the payload, the head script does not, and both write identical GITHUB_OUTPUT.
  • No Go code changed, so I ran no go build or test.

Body-stale:

  • "zizmor's template-injection audit goes from 58 findings to 0": at the current merge base, zizmor 1.29.0 reports 62 before. The "after" figure of 0 is still correct.

Reviewer: rB8. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths.

@IAmJSD

IAmJSD commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator Author

Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE a20cf84124366c34a0c90f896b6f4266b00a9021

Blocking:

  • none

Non-blocking:

Attacked:

  • Read every hunk. Each env value is the original expression copied verbatim, including || 'all'. matrix is allowed in job-level env:. AZURE_LOCATION/GCP_REGION are workflow-level only, with no job override and no $GITHUB_ENV writer in those files, so ${{ env.X }} and $X are the same. The new names follow the existing conventions in deploy-aws-lambda/database-migration and shadow nothing a tool reads. GCP_PROJECT_ID is not a Google provider env var, and terraform only reads TF_VAR_*. The only quoting changes ("$IMAGE", "terraform/environments/$CLOUD") are on fixed-shape values.
  • Census (the issue's YAML parser over jobs.*.steps[].run): 0 at the head, and 0 on git merge-tree HEAD origin/main (main has moved, and merges clean).
  • zizmor 1.29.0 --offline --persona=pedantic --min-severity=medium (the CI gate): no findings on the head or the merged tree. The JSON at pedantic shows only 24 undocumented-permissions and 11 concurrency-limits, with 0 template-injection, which matches the updated ci.yml comment.
  • actionlint v1.7.12 (with shellcheck on PATH): clean on the changed files and on all workflows in the merged tree. PR checks are all green. No workflow was run. The deploy-* changes only take effect on a deploy or release run, which has never fired (issue chore(ci): finish the #1641 sweep - 159 ${{ }} interpolations remain in run: blocks across 7 workflows #155).

Body-stale:

Reviewer: rB12. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths.

@IAmJSD

IAmJSD commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator Author

Local verification at a20cf84124366c34a0c90f896b6f4266b00a9021 merged onto main (51957bcf), together with #1100, #1108, #1147, #1149, #1276, #1292 in that order (combined tree bc575de3, Linux, Go 1.26.10): go build ./..., go vet with and without -tags integration, the full go test -race -short -count=1 ./..., and go test -race -tags integration -count=1 on every changed package, against Docker Postgres. All pass. CI Success and pre-commit are green on this head, with 2 approving independent review(s) on it. Nothing was run against any cloud.

@IAmJSD
IAmJSD marked this pull request as ready for review October 25, 2026 20:54
@IAmJSD
IAmJSD merged commit acd20b3 into LeanerCloud:main Oct 25, 2026
35 checks passed
@IAmJSD

IAmJSD commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator Author

Merged by IAmJSD's agent at IAmJSD's request (IAmJSD is acting owner while the maintainers are away), under the bar IAmJSD set: 2 approving independent review(s) on a20cf841 (two required: money, auth or security path), green CI, and the combined-tree verification above. Squash-merged at exactly a20cf84124366c34a0c90f896b6f4266b00a9021.

IAmJSD added a commit to IAmJSD/cloud-commitments-platform that referenced this pull request Oct 25, 2026
Main's LeanerCloud#1147 moved every run-block ${{ }} interpolation in ci.yml into
env, giving terraform-validate a job-level CLOUD. The step this branch
adds still interpolated matrix.cloud straight into its run block, which
the merge brought back. Use the quoted CLOUD env like the neighbouring
steps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
IAmJSD added a commit that referenced this pull request Oct 25, 2026
…#1255)

* fix(iac): require PostgreSQL 15+ in every environment's version input

The migrations need PostgreSQL 15 (000134 uses UNIQUE NULLS NOT
DISTINCT), but the environment inputs still accepted older majors:
Azure's postgres_version allowed 11-16, and AWS database_engine_version
and GCP database_version had no validation. A custom deployment on an
older engine would plan and apply, then fail at migration time.

Validate all three at plan time: Azure "15"/"16", GCP
POSTGRES_15/POSTGRES_16, and AWS "<major>" or "<major>.<minor>" with a
major of 15 or more (try() makes a malformed value a validation failure
rather than an evaluation error). Every shipped tfvars pins 16, so no
deployed environment changes.

Add a mocked tftest per environment (old major rejected, 15 and 16
accepted), run by a new "Test environment inputs" step in the
terraform-validate CI job, and state the minimum in docs/DEPLOYMENT.md.

Closes #1233

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(iac): allowlist the mocked caller-identity account ID in the PG15 tftest

The combined-tree git-secrets run flagged
terraform/environments/aws/tests/postgres_version.tftest.hcl:10, a bare
account_id = "123456789012" in the mocked aws_caller_identity. The repo
allows that placeholder only on AccountID lines in _test.go files, so add
an anchored whole-line entry for this one mock line (bracketed digits so
the entry does not match itself), like the other terraform/ entries.

Refs #1233

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ci): read the cloud from CLOUD in the environment-inputs test step

Main's #1147 moved every run-block ${{ }} interpolation in ci.yml into
env, giving terraform-validate a job-level CLOUD. The step this branch
adds still interpolated matrix.cloud straight into its run block, which
the merge brought back. Use the quoted CLOUD env like the neighbouring
steps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
IAmJSD added a commit to IAmJSD/cloud-commitments-platform that referenced this pull request Oct 26, 2026
Conflict: .github/workflows/deploy-all.yml, three hunks against LeanerCloud#1147,
which moved every run-block ${{ }} into env:. Kept main's env-variable
form throughout (the injection hardening must stay) and dropped the AWS
Fargate lines LeanerCloud#1147 added to the plan summary, the result summary and
check_job, plus the three DEPLOY_AWS_FARGATE* env entries that pointed
at the deploy-aws-fargate job this PR removes. This is the resolution the
PR body describes. actionlint and zizmor are clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/m Days impact/internal Team-internal only priority/p2 Backlog-worthy severity/medium Moderate harm triaged Item has been triaged type/chore Maintenance / non-user-visible urgency/this-quarter Within the quarter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

chore(ci): finish the #1641 sweep - 159 ${{ }} interpolations remain in run: blocks across 7 workflows

2 participants