From 5a14211038f31c0631e118fea9e5f23493a127e5 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Sun, 8 Nov 2026 09:03:15 +0200 Subject: [PATCH 1/6] fix(gh-aw): restore hosted E2E and migrate package identity - The hosted E2E guard keys on the repository ID instead of the owner name, so it runs again and fails loudly after any future rename - github/squad/workflows becomes the canonical package identity; existing bradygaster/squad/workflows installs still verify (exact allowlist), and mixed or duplicate ownership records fail with guidance - Install and upgrade docs check the revision's manifest before gh aw add - uses: github/squad/.github/actions/squad-init (same pinned SHA); the action's default release repository is github/squad - Package regenerated with squad-install-verifier --write-source Closes #2403 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../repo-move-github-squad-gh-aw-identity.md | 6 + .github/actions/squad-init/action.yml | 4 +- .github/agents.md | 8 +- .github/workflows/squad-gh-aw-hosted-e2e.yml | 4 +- .../skills/gh-aw-enlistment/SKILL.md | 117 ++++++- README.md | 21 +- .../docs/features/standalone-install.md | 2 +- docs/src/content/docs/guide/gh-aw.md | 122 ++++++- .../skills/gh-aw-enlistment/SKILL.md | 117 ++++++- .../skills/gh-aw-enlistment/SKILL.md | 117 ++++++- scripts/gh-aw-hosted-e2e.mjs | 18 +- templates/skills/gh-aw-enlistment/SKILL.md | 117 ++++++- test/fixtures/squad-init-approved.json | 2 +- test/gh-aw-enlistment-skill.test.ts | 327 +++++++++++++++++- test/gh-aw-hosted-e2e.test.ts | 107 +++++- test/gh-aw-implement-workflow.test.ts | 5 +- test/gh-aw-install-staging.test.ts | 102 +++++- test/gh-aw-quality.test.ts | 207 +++++++++-- test/gh-aw-review-workflow.test.ts | 21 +- test/gh-aw-verify-resource.test.ts | 26 +- test/helpers/gh-aw-install-fixture.ts | 18 +- test/helpers/refresh-squad-init-fixture.mjs | 4 +- test/squad-cli-latest.test.ts | 4 +- workflows/README.md | 14 +- workflows/package/squad-bootstrap.md | 2 +- workflows/package/squad-deps-worker.md | 2 +- workflows/package/squad-implement-worker.md | 2 +- workflows/package/squad-retro.md | 2 +- workflows/package/squad.md | 2 +- workflows/shared/squad-install-verifier.mjs | 180 ++++++++-- workflows/shared/squad.md | 6 +- workflows/skills/gh-aw-enlistment/SKILL.md | 117 ++++++- workflows/squad-bootstrap.md | 2 +- workflows/squad-workflows.manifest.json | 82 ++--- 34 files changed, 1666 insertions(+), 221 deletions(-) create mode 100644 .changeset/repo-move-github-squad-gh-aw-identity.md diff --git a/.changeset/repo-move-github-squad-gh-aw-identity.md b/.changeset/repo-move-github-squad-gh-aw-identity.md new file mode 100644 index 000000000..11d9241a4 --- /dev/null +++ b/.changeset/repo-move-github-squad-gh-aw-identity.md @@ -0,0 +1,6 @@ +--- +'@bradygaster/squad-cli': patch +'@bradygaster/squad-sdk': patch +--- + +Update the shipped gh-aw-enlistment skill for the repository's new home, `github/squad`. It now installs the gh-aw package as `github/squad/workflows`, links to the docs guide's new address, and allowlists the `github/squad/.github/actions/squad-init` action. Before installing or upgrading, it checks that the chosen Squad revision declares `github/squad/workflows` in its package manifest, and stops otherwise. It also explains how to move an installation off the legacy `bradygaster/squad/workflows` identity, which the install verifier still accepts. diff --git a/.github/actions/squad-init/action.yml b/.github/actions/squad-init/action.yml index 8c73caa75..235b6f2cc 100644 --- a/.github/actions/squad-init/action.yml +++ b/.github/actions/squad-init/action.yml @@ -9,7 +9,7 @@ # and the RFC in #1593). # # Usage: -# - uses: bradygaster/squad/.github/actions/squad-init@ +# - uses: github/squad/.github/actions/squad-init@ # with: # version: 'v0.11.0' # default: latest release # preset: 'default' # default: default @@ -41,7 +41,7 @@ inputs: repository: description: 'Repository to download release assets from. Point this at an internal mirror when github.com is unreachable.' required: false - default: 'bradygaster/squad' + default: 'github/squad' skip-init: description: 'Install the CLI but do not run squad init' required: false diff --git a/.github/agents.md b/.github/agents.md index cc0f14c41..ab61d4e10 100644 --- a/.github/agents.md +++ b/.github/agents.md @@ -49,10 +49,16 @@ The quick start installs this workflow set: ```bash SQUAD_SHA="<40-character-commit-sha>" # explicit, maintainer-approved; never `commits/dev` -gh aw add "bradygaster/squad/workflows@${SQUAD_SHA}" +gh aw add "github/squad/workflows@${SQUAD_SHA}" rm -f .github/skills/agentic-workflows/SKILL.md ``` +`github/squad/workflows` verifies only at Squad revisions whose +`workflows/squad-workflows.manifest.json` declares +`"package": "github/squad/workflows"`; the quick start's identity check stops +on any other revision. Install an older maintainer-approved revision as +`bradygaster/squad/workflows`, or ask the Squad maintainers for a newer revision. + The removal is intentional: gh-aw v0.91.5 generates that generic tool-owned router, which loads mutable prompts from the current `github/gh-aw` repository. It is not Squad-owned or bound to `SQUAD_SHA`. Keep the exact diff --git a/.github/workflows/squad-gh-aw-hosted-e2e.yml b/.github/workflows/squad-gh-aw-hosted-e2e.yml index 53b773d59..73c2e38c0 100644 --- a/.github/workflows/squad-gh-aw-hosted-e2e.yml +++ b/.github/workflows/squad-gh-aw-hosted-e2e.yml @@ -23,8 +23,10 @@ concurrency: jobs: hosted-e2e: name: Install, merge, bootstrap, and probe + # Bound to the immutable repository ID of github/squad rather than its name, so a + # rename or transfer reaches source-preflight and fails loudly instead of skipping. if: >- - github.repository == 'bradygaster/squad' + github.repository_id == '1151205052' && github.ref_name == github.event.repository.default_branch runs-on: ubuntu-latest timeout-minutes: 45 diff --git a/.squad-templates/skills/gh-aw-enlistment/SKILL.md b/.squad-templates/skills/gh-aw-enlistment/SKILL.md index 0a02b6d01..91043f57a 100644 --- a/.squad-templates/skills/gh-aw-enlistment/SKILL.md +++ b/.squad-templates/skills/gh-aw-enlistment/SKILL.md @@ -25,7 +25,7 @@ bootstrap pull request** — never an auto-merge. This skill **operationalizes** the supported bootstrap path. It does not summarize it: each step below is a gate with explicit success evidence and **STOP conditions**. The authoritative source is `docs/src/content/docs/guide/gh-aw.md` (mirrored at -`https://bradygaster.github.io/squad/docs/guide/gh-aw/`). If that guide and this +`https://github.github.com/squad/docs/guide/gh-aw/`). If that guide and this skill ever disagree, the guide wins — re-read it before proceeding. GitHub Issues are a hard prerequisite: `/squad` commands arrive as issue @@ -193,10 +193,34 @@ published release guidance, then set it once: echo "STOP: SQUAD_SHA must be the exact 40-character commit SHA, not a branch name or shortened hash." >&2 exit 1 } -gh aw add "bradygaster/squad/workflows@${SQUAD_SHA}" +# squad-package-identity-start +squad_package="$(gh api --hostname github.com -H 'Accept: application/vnd.github.raw+json' \ + "repos/github/squad/contents/workflows/squad-workflows.manifest.json?ref=${SQUAD_SHA}" \ + --jq '.package')" || squad_package="" +test "${squad_package}" = "github/squad/workflows" || { + if [ -z "${squad_package}" ]; then + echo "STOP: cannot read workflows/squad-workflows.manifest.json from github/squad at ${SQUAD_SHA}." >&2 + echo "Check gh auth status --hostname github.com and network access, and confirm SQUAD_SHA is a github/squad commit that has this manifest, then retry." >&2 + else + echo "STOP: Squad ${SQUAD_SHA} does not declare the github/squad/workflows package (found '${squad_package}')." >&2 + echo "Install a revision that declares bradygaster/squad/workflows under that identity, or ask the Squad maintainers for a newer revision." >&2 + fi + exit 1 +} +# squad-package-identity-end +gh aw add "github/squad/workflows@${SQUAD_SHA}" rm -f .github/skills/agentic-workflows/SKILL.md ``` +`github/squad/workflows` verifies only at Squad revisions whose +`workflows/squad-workflows.manifest.json` declares +`"package": "github/squad/workflows"`. The identity check above reads that +manifest at `SQUAD_SHA` and stops on any other revision. An older +maintainer-approved revision declares `bradygaster/squad/workflows`: install it +under that identity, because its verifier accepts no other, or ask the Squad +maintainers for a newer revision. Such a revision's safe-update report names +the same squad-init action as `bradygaster/squad/.github/actions/squad-init`. + The nested `workflows/aw.yml` is the only supported distribution registration. It isolates package auto-discovery from unrelated repository skills and agents, and installs exactly eight workflows, seventeen runtime resources, and one @@ -226,6 +250,61 @@ that exact file after every `gh aw add`. Keep the verifier requires its exact package bytes. Do not adopt or vendor the rest of gh-aw's generic scaffold. +Revisions whose `workflows/squad-workflows.manifest.json` declares +`"package": "github/squad/workflows"` verify that identity and the legacy +`bradygaster/squad/workflows` identity. Earlier revisions, including ones +committed after the repository moved to `github/squad`, declare and verify only +`bradygaster/squad/workflows`, and installations made from them carry it in +their ownership record, workflow `source:` lines, and locks. Upgrade such an +installation to a revision that declares `github/squad/workflows` with the block +below. gh-aw keeps the superseded +`.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json` after the +forced reinstall, so the block deletes exactly that file before verifying; the +verifier fails closed while both ownership records exist. That first upgrade +also makes `gh aw add` report one safe-update change: action +`github/squad/.github/actions/squad-init` added and +`bradygaster/squad/.github/actions/squad-init` removed. It is the same +squad-init action under the repository's new owner; list it in the PR's +security review note. **STOP** on any other new secret or action. Until an +installation moves, `--verify-install` still passes and prints a notice naming +the legacy identity. To stay on a revision that declares +`bradygaster/squad/workflows`, replace the identity check and the `gh aw add` +line below with `gh aw add "bradygaster/squad/workflows@${SQUAD_SHA}" --force`, +and skip the second `rm -f`: that ownership record is the only one such an +installation has. + +```bash +: "${SQUAD_SHA:?STOP: set SQUAD_SHA to an explicit, maintainer-approved 40-character Squad commit SHA before upgrading.}" +[[ "${SQUAD_SHA}" =~ ^[0-9a-f]{40}$ ]] || { + echo "STOP: SQUAD_SHA must be the exact 40-character commit SHA, not a branch name or shortened hash." >&2 + exit 1 +} +squad_package="$(gh api --hostname github.com -H 'Accept: application/vnd.github.raw+json' \ + "repos/github/squad/contents/workflows/squad-workflows.manifest.json?ref=${SQUAD_SHA}" \ + --jq '.package')" || squad_package="" +test "${squad_package}" = "github/squad/workflows" || { + if [ -z "${squad_package}" ]; then + echo "STOP: cannot read workflows/squad-workflows.manifest.json from github/squad at ${SQUAD_SHA}." >&2 + echo "Check gh auth status --hostname github.com and network access, and confirm SQUAD_SHA is a github/squad commit that has this manifest, then retry." >&2 + else + echo "STOP: Squad ${SQUAD_SHA} does not declare the github/squad/workflows package (found '${squad_package}')." >&2 + echo "Install a revision that declares bradygaster/squad/workflows under that identity, or ask the Squad maintainers for a newer revision." >&2 + fi + exit 1 +} +gh aw add "github/squad/workflows@${SQUAD_SHA}" --force +rm -f .github/skills/agentic-workflows/SKILL.md +rm -f .github/aw/packages/bradygaster-squad-workflows-3632054824e8.json +node .github/workflows/shared/squad-install-verifier.mjs --materialize-runtime +gh aw compile --strict +node .github/workflows/shared/squad-install-verifier.mjs \ + --verify-install \ + --source-revision "${SQUAD_SHA}" \ + --strict-compile +``` + +Apply step 5's two-warning check to the compile output above, then stage and verify as in step 7. + Report/proposal-only is the default. Ordinary fixes require the explicit `"squadRetroAutoImplement": "allow"` setting in `.squad/config.json`; five action issues and three dispatches per wake-up remain separate caps. @@ -247,9 +326,13 @@ On a clean repo, `gh aw add` reports these expected safe updates and **nothing e - Restricted secrets: **`SQUAD_GITHUB_APP_PRIVATE_KEY`** and **`SQUAD_GITHUB_TOKEN`** -- Action: **`bradygaster/squad/.github/actions/squad-init`** +- Action: **`github/squad/.github/actions/squad-init`** +At a revision whose manifest declares `bradygaster/squad/workflows`, the same +action is reported as `bradygaster/squad/.github/actions/squad-init`. Accept +that name only for such a revision. + > **These are referenced names, not prerequisites.** `gh aw add` lists the secrets > the workflows *reference* so you can approve that surface — it is not asking you > to supply them. Both secrets are optional, they need not exist, and neither is @@ -349,7 +432,8 @@ node .github/workflows/shared/squad-install-verifier.mjs \ git diff --cached --stat unexpected_deletions="$( git diff --cached --diff-filter=D --name-only | - grep -vxF '.github/skills/agentic-workflows/SKILL.md' || true + grep -vxF -e '.github/skills/agentic-workflows/SKILL.md' \ + -e '.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json' || true )" test -z "${unexpected_deletions}" || { printf 'STOP: unexpected staged deletions:\n%s\n' "${unexpected_deletions}" >&2 @@ -357,9 +441,11 @@ test -z "${unexpected_deletions}" || { } ``` -- The only permitted staged deletion is +- The only permitted staged deletions are `.github/skills/agentic-workflows/SKILL.md`, when upgrading a repository that - previously committed gh-aw's mutable router. + previously committed gh-aw's mutable router, and + `.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json`, when moving + an existing installation off the legacy `bradygaster/squad/workflows` identity. - **STOP** if the staged diff shows any other **unexpected deletions**, **unexpected secrets**, edits to **unrelated files**, or committed **log/diagnostic output**. Re-scope with explicit `git add -- ` — never `git add .`, `git add -A`, or `git commit -a`. @@ -498,7 +584,20 @@ git switch -c chore/squad-gh-aw-bootstrap echo "STOP: SQUAD_SHA must be an explicit, maintainer-approved 40-character lowercase hex commit SHA." >&2 exit 1 } -gh aw add "bradygaster/squad/workflows@${SQUAD_SHA}" +squad_package="$(gh api --hostname github.com -H 'Accept: application/vnd.github.raw+json' \ + "repos/github/squad/contents/workflows/squad-workflows.manifest.json?ref=${SQUAD_SHA}" \ + --jq '.package')" || squad_package="" +test "${squad_package}" = "github/squad/workflows" || { + if [ -z "${squad_package}" ]; then + echo "STOP: cannot read workflows/squad-workflows.manifest.json from github/squad at ${SQUAD_SHA}." >&2 + echo "Check gh auth status --hostname github.com and network access, and confirm SQUAD_SHA is a github/squad commit that has this manifest, then retry." >&2 + else + echo "STOP: Squad ${SQUAD_SHA} does not declare the github/squad/workflows package (found '${squad_package}')." >&2 + echo "Install a revision that declares bradygaster/squad/workflows under that identity, or ask the Squad maintainers for a newer revision." >&2 + fi + exit 1 +} +gh aw add "github/squad/workflows@${SQUAD_SHA}" rm -f .github/skills/agentic-workflows/SKILL.md # Safe-update report shows ONLY the two documented secrets + squad-init → approve once @@ -566,7 +665,9 @@ gh pr merge --squash # auto-merge before human review. NEVER. `.github/skills/agentic-workflows/SKILL.md`; keep the exact Squad-owned `.github/skills/gh-aw-enlistment/SKILL.md`. - ❌ **Approving unknown safe updates.** Approve ONLY `SQUAD_GITHUB_APP_PRIVATE_KEY`, - `SQUAD_GITHUB_TOKEN`, and `bradygaster/squad/.github/actions/squad-init`. Anything + `SQUAD_GITHUB_TOKEN`, and `github/squad/.github/actions/squad-init` (reported + as `bradygaster/squad/.github/actions/squad-init` only at a revision whose + manifest declares `bradygaster/squad/workflows`). Anything else is a STOP. - ❌ **Treating `--approve` as the final compile.** Always finish with a plain `gh aw compile --strict` (no `--approve`). diff --git a/README.md b/README.md index 2f6999313..a0b901945 100644 --- a/README.md +++ b/README.md @@ -600,7 +600,20 @@ test "$(gh api "repos/${owner_repo}" --jq '.has_issues')" = "true" || { } SQUAD_SHA="<40-character-commit-sha>" # explicit, maintainer-approved; never `commits/dev` -gh aw add "bradygaster/squad/workflows@${SQUAD_SHA}" +squad_package="$(gh api --hostname github.com -H 'Accept: application/vnd.github.raw+json' \ + "repos/github/squad/contents/workflows/squad-workflows.manifest.json?ref=${SQUAD_SHA}" \ + --jq '.package')" || squad_package="" +test "${squad_package}" = "github/squad/workflows" || { + if [ -z "${squad_package}" ]; then + echo "STOP: cannot read workflows/squad-workflows.manifest.json from github/squad at ${SQUAD_SHA}." >&2 + echo "Check gh auth status --hostname github.com and network access, and confirm SQUAD_SHA is a github/squad commit that has this manifest, then retry." >&2 + else + echo "STOP: Squad ${SQUAD_SHA} does not declare the github/squad/workflows package (found '${squad_package}')." >&2 + echo "Install a revision that declares bradygaster/squad/workflows under that identity, or ask the Squad maintainers for a newer revision." >&2 + fi + exit 1 +} +gh aw add "github/squad/workflows@${SQUAD_SHA}" rm -f .github/skills/agentic-workflows/SKILL.md git add -- \ .github/aw/ \ @@ -611,6 +624,12 @@ git commit -m "Add Squad workflow" git push ``` +`github/squad/workflows` verifies only at Squad revisions whose +`workflows/squad-workflows.manifest.json` declares +`"package": "github/squad/workflows"`, so the identity check stops on any other +revision. Install an older maintainer-approved revision as +`bradygaster/squad/workflows`, or ask the Squad maintainers for a newer revision. + `gh aw add` compiles the workflows automatically. If it reports unapproved safe-update changes, review them and run `gh aw compile --approve`. diff --git a/docs/src/content/docs/features/standalone-install.md b/docs/src/content/docs/features/standalone-install.md index ece2d1cda..83e537d87 100644 --- a/docs/src/content/docs/features/standalone-install.md +++ b/docs/src/content/docs/features/standalone-install.md @@ -131,7 +131,7 @@ for local development only and must not be configured in GitHub Actions. The `squad-init` action wraps the install and init steps: ```yaml -- uses: bradygaster/squad/.github/actions/squad-init@ +- uses: github/squad/.github/actions/squad-init@ with: version: v0.13.1 # default: latest release preset: default diff --git a/docs/src/content/docs/guide/gh-aw.md b/docs/src/content/docs/guide/gh-aw.md index 0ce64aa73..d5b4d4107 100644 --- a/docs/src/content/docs/guide/gh-aw.md +++ b/docs/src/content/docs/guide/gh-aw.md @@ -104,20 +104,37 @@ fi # 4. Install the complete native package at an explicit, maintainer-approved # revision. SQUAD_SHA is never resolved from the `dev` branch's moving tip — # ask the Squad maintainers (or check the project's published release -# guidance) for the current supported revision. +# guidance) for the current supported revision. The identity check stops unless +# that revision's workflows/squad-workflows.manifest.json declares +# "package": "github/squad/workflows". SQUAD_SHA="<40-character-commit-sha>" [[ "${SQUAD_SHA}" =~ ^[0-9a-f]{40}$ ]] || { echo "STOP: SQUAD_SHA must be an explicit 40-character Squad commit SHA, not a branch name or shortened hash." >&2 exit 1 } +# squad-package-identity-start +squad_package="$(gh api --hostname github.com -H 'Accept: application/vnd.github.raw+json' \ + "repos/github/squad/contents/workflows/squad-workflows.manifest.json?ref=${SQUAD_SHA}" \ + --jq '.package')" || squad_package="" +test "${squad_package}" = "github/squad/workflows" || { + if [ -z "${squad_package}" ]; then + echo "STOP: cannot read workflows/squad-workflows.manifest.json from github/squad at ${SQUAD_SHA}." >&2 + echo "Check gh auth status --hostname github.com and network access, and confirm SQUAD_SHA is a github/squad commit that has this manifest, then retry." >&2 + else + echo "STOP: Squad ${SQUAD_SHA} does not declare the github/squad/workflows package (found '${squad_package}')." >&2 + echo "Install a revision that declares bradygaster/squad/workflows under that identity, or ask the Squad maintainers for a newer revision." >&2 + fi + exit 1 +} +# squad-package-identity-end -gh aw add "bradygaster/squad/workflows@${SQUAD_SHA}" +gh aw add "github/squad/workflows@${SQUAD_SHA}" rm -f .github/skills/agentic-workflows/SKILL.md # 5. On first install, review the safe-update report. Approve ONLY if it # contains exactly the two documented secrets (SQUAD_GITHUB_APP_PRIVATE_KEY, # SQUAD_GITHUB_TOKEN) and the one documented action -# (bradygaster/squad/.github/actions/squad-init) and nothing else. STOP and +# (github/squad/.github/actions/squad-init) and nothing else. STOP and # report any other entry instead of approving. gh aw compile --strict --approve # first install only, when the safe-update warning appears @@ -139,7 +156,8 @@ node .github/workflows/shared/squad-install-verifier.mjs \ git diff --cached --stat unexpected_deletions="$( git diff --cached --diff-filter=D --name-only | - grep -vxF '.github/skills/agentic-workflows/SKILL.md' || true + grep -vxF -e '.github/skills/agentic-workflows/SKILL.md' \ + -e '.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json' || true )" test -z "${unexpected_deletions}" git commit -m "ci: add Squad agentic workflow" @@ -550,10 +568,31 @@ SQUAD_SHA="<40-character-commit-sha>" echo "STOP: SQUAD_SHA must be an explicit 40-character Squad commit SHA, not a branch name or shortened hash." >&2 exit 1 } -gh aw add "bradygaster/squad/workflows@${SQUAD_SHA}" +squad_package="$(gh api --hostname github.com -H 'Accept: application/vnd.github.raw+json' \ + "repos/github/squad/contents/workflows/squad-workflows.manifest.json?ref=${SQUAD_SHA}" \ + --jq '.package')" || squad_package="" +test "${squad_package}" = "github/squad/workflows" || { + if [ -z "${squad_package}" ]; then + echo "STOP: cannot read workflows/squad-workflows.manifest.json from github/squad at ${SQUAD_SHA}." >&2 + echo "Check gh auth status --hostname github.com and network access, and confirm SQUAD_SHA is a github/squad commit that has this manifest, then retry." >&2 + else + echo "STOP: Squad ${SQUAD_SHA} does not declare the github/squad/workflows package (found '${squad_package}')." >&2 + echo "Install a revision that declares bradygaster/squad/workflows under that identity, or ask the Squad maintainers for a newer revision." >&2 + fi + exit 1 +} +gh aw add "github/squad/workflows@${SQUAD_SHA}" rm -f .github/skills/agentic-workflows/SKILL.md ``` +`github/squad/workflows` verifies only at Squad revisions whose +`workflows/squad-workflows.manifest.json` declares +`"package": "github/squad/workflows"`, so the identity check reads that manifest +at `SQUAD_SHA` and stops on any other revision. An older maintainer-approved +revision declares `bradygaster/squad/workflows`: install it under that +identity, because its verifier accepts no other, or ask the Squad maintainers +for a newer revision. + The nested `workflows/aw.yml` is the canonical package registration. Its package boundary prevents unrelated repository skills or agents from being auto-discovered. It installs exactly eight agentic workflows, twenty-one runtime @@ -641,7 +680,10 @@ source/lock pairs, and one coherent 40-character revision. On a clean repository, `gh aw add` reports these expected safe-update changes: - Restricted secrets: `SQUAD_GITHUB_APP_PRIVATE_KEY` and `SQUAD_GITHUB_TOKEN` -- Action: `bradygaster/squad/.github/actions/squad-init` +- Action: `github/squad/.github/actions/squad-init` + +At a revision whose manifest declares `bradygaster/squad/workflows`, the same +action is reported as `bradygaster/squad/.github/actions/squad-init`. > **These are referenced names, not prerequisites.** `gh aw add` lists the secrets > the workflows *reference* so you can approve that surface — it is not asking you @@ -721,7 +763,8 @@ node .github/workflows/shared/squad-install-verifier.mjs \ git diff --cached --stat unexpected_deletions="$( git diff --cached --diff-filter=D --name-only | - grep -vxF '.github/skills/agentic-workflows/SKILL.md' || true + grep -vxF -e '.github/skills/agentic-workflows/SKILL.md' \ + -e '.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json' || true )" test -z "${unexpected_deletions}" git commit -m "ci: add Squad agentic workflow" @@ -745,9 +788,11 @@ state under `.github/aw/`, the installed skills, and `.gitattributes`. Review the complete generated diff in the bootstrap PR, address Copilot review feedback, and wait for required checks. Merge only after human approval. -The exact router deletion above is the only permitted staged deletion, for an -upgrade from a repository that previously committed it. Any other deletion is -a hard stop. +The only permitted staged deletions are the exact router deletion above, for an +upgrade from a repository that previously committed it, and the superseded +`.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json` ownership +record, for an installation moving off the legacy `bradygaster/squad/workflows` +identity (see Upgrading). Any other deletion is a hard stop. Consumer ignore rules such as `packages/` can silently omit the required `.github/aw/packages/` ownership JSON from directory staging. The staged verifier @@ -2506,9 +2551,23 @@ same native package as one unit: ```bash SQUAD_SHA="<40-character-commit-sha>" +squad_package="$(gh api --hostname github.com -H 'Accept: application/vnd.github.raw+json' \ + "repos/github/squad/contents/workflows/squad-workflows.manifest.json?ref=${SQUAD_SHA}" \ + --jq '.package')" || squad_package="" +test "${squad_package}" = "github/squad/workflows" || { + if [ -z "${squad_package}" ]; then + echo "STOP: cannot read workflows/squad-workflows.manifest.json from github/squad at ${SQUAD_SHA}." >&2 + echo "Check gh auth status --hostname github.com and network access, and confirm SQUAD_SHA is a github/squad commit that has this manifest, then retry." >&2 + else + echo "STOP: Squad ${SQUAD_SHA} does not declare the github/squad/workflows package (found '${squad_package}')." >&2 + echo "Install a revision that declares bradygaster/squad/workflows under that identity, or ask the Squad maintainers for a newer revision." >&2 + fi + exit 1 +} -gh aw add "bradygaster/squad/workflows@${SQUAD_SHA}" --force +gh aw add "github/squad/workflows@${SQUAD_SHA}" --force rm -f .github/skills/agentic-workflows/SKILL.md +rm -f .github/aw/packages/bradygaster-squad-workflows-3632054824e8.json node .github/workflows/shared/squad-install-verifier.mjs --materialize-runtime gh aw compile --strict node .github/workflows/shared/squad-install-verifier.mjs \ @@ -2523,16 +2582,51 @@ then reapply them before the final compile. Never customize generated ownership record, missing source/lock pair, or absent runtime resource and prints the exact safe recovery commands. +Revisions whose `workflows/squad-workflows.manifest.json` declares +`"package": "github/squad/workflows"` verify that identity and the legacy +`bradygaster/squad/workflows` identity. Earlier revisions, including ones +committed after the repository moved to `github/squad`, declare and verify only +`bradygaster/squad/workflows`, and installations made from them carry it. The +identity check above stops unless the target revision declares +`github/squad/workflows`. To stay on an earlier revision instead, replace the +identity check and the `gh aw add` line with +`gh aw add "bradygaster/squad/workflows@${SQUAD_SHA}" --force`, and skip the +second `rm -f`: that ownership record is the only one such an installation has. +Otherwise ask the Squad maintainers for a newer revision. + +Upgrading a legacy installation to a revision that declares +`github/squad/workflows` installs `github/squad/workflows`. gh-aw keeps the +superseded +`.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json` ownership +record after that forced reinstall, so the second `rm -f` above deletes it; the +verifier fails closed while both records exist. Stage that deletion with the +rest of `.github/aw/`. That first upgrade also makes `gh aw add` report one +safe-update change: action +`github/squad/.github/actions/squad-init` added and +`bradygaster/squad/.github/actions/squad-init` removed. It is the same +squad-init action under the repository's new owner. List it in the pull +request's security review note, and stop on any other new secret or action. +If your organization allows only selected actions, allow +`github/squad/.github/actions/squad-init` (or actions created by GitHub) +before merging the upgrade. Until an installation moves, `--verify-install` +still passes and prints a notice naming the legacy identity. + The schema-v2 integrity manifest records two complete compiled lock digests per workflow: `lock_sha256` for a per-workflow source annotation and `package_lock_sha256` for a package-root annotation. gh-aw v0.91.5 emits -`bradygaster/squad/workflows@SHA` for direct package includes, but can emit -`bradygaster/squad/workflows/package/.md@SHA` when installing dispatched +`github/squad/workflows@SHA` for direct package includes, but can emit +`github/squad/workflows/package/.md@SHA` when installing dispatched dependencies. Fresh and forced installs can therefore contain different mixtures. The verifier selects one digest from the exact, ownership- and source-validated annotation; it never tries both until one passes. Compiled source comments, URLs, action pins, permissions and runtime bytes remain integrity-checked. -No additional fields are removed during lock normalization. +No additional fields are removed during lock normalization. Locks installed +under the legacy `bradygaster/squad/workflows` identity are compared after +that identity is rewritten to `github/squad/workflows` in their source +metadata only: the `# Source:` and `# To update this file` comments, +`GH_AW_WORKFLOW_SOURCE`, `GH_AW_INFO_FRONTMATTER_SOURCE` and +`GH_AW_WORKFLOW_SOURCE_URL`. A legacy lock that already contains +`github/squad/workflows` source metadata fails verification. The manifest also contains a separately compiled, full-lock digest variant for consumers that explicitly set `maintenance.action_failure_issue_expires` to `24` diff --git a/packages/squad-cli/templates/skills/gh-aw-enlistment/SKILL.md b/packages/squad-cli/templates/skills/gh-aw-enlistment/SKILL.md index 0a02b6d01..91043f57a 100644 --- a/packages/squad-cli/templates/skills/gh-aw-enlistment/SKILL.md +++ b/packages/squad-cli/templates/skills/gh-aw-enlistment/SKILL.md @@ -25,7 +25,7 @@ bootstrap pull request** — never an auto-merge. This skill **operationalizes** the supported bootstrap path. It does not summarize it: each step below is a gate with explicit success evidence and **STOP conditions**. The authoritative source is `docs/src/content/docs/guide/gh-aw.md` (mirrored at -`https://bradygaster.github.io/squad/docs/guide/gh-aw/`). If that guide and this +`https://github.github.com/squad/docs/guide/gh-aw/`). If that guide and this skill ever disagree, the guide wins — re-read it before proceeding. GitHub Issues are a hard prerequisite: `/squad` commands arrive as issue @@ -193,10 +193,34 @@ published release guidance, then set it once: echo "STOP: SQUAD_SHA must be the exact 40-character commit SHA, not a branch name or shortened hash." >&2 exit 1 } -gh aw add "bradygaster/squad/workflows@${SQUAD_SHA}" +# squad-package-identity-start +squad_package="$(gh api --hostname github.com -H 'Accept: application/vnd.github.raw+json' \ + "repos/github/squad/contents/workflows/squad-workflows.manifest.json?ref=${SQUAD_SHA}" \ + --jq '.package')" || squad_package="" +test "${squad_package}" = "github/squad/workflows" || { + if [ -z "${squad_package}" ]; then + echo "STOP: cannot read workflows/squad-workflows.manifest.json from github/squad at ${SQUAD_SHA}." >&2 + echo "Check gh auth status --hostname github.com and network access, and confirm SQUAD_SHA is a github/squad commit that has this manifest, then retry." >&2 + else + echo "STOP: Squad ${SQUAD_SHA} does not declare the github/squad/workflows package (found '${squad_package}')." >&2 + echo "Install a revision that declares bradygaster/squad/workflows under that identity, or ask the Squad maintainers for a newer revision." >&2 + fi + exit 1 +} +# squad-package-identity-end +gh aw add "github/squad/workflows@${SQUAD_SHA}" rm -f .github/skills/agentic-workflows/SKILL.md ``` +`github/squad/workflows` verifies only at Squad revisions whose +`workflows/squad-workflows.manifest.json` declares +`"package": "github/squad/workflows"`. The identity check above reads that +manifest at `SQUAD_SHA` and stops on any other revision. An older +maintainer-approved revision declares `bradygaster/squad/workflows`: install it +under that identity, because its verifier accepts no other, or ask the Squad +maintainers for a newer revision. Such a revision's safe-update report names +the same squad-init action as `bradygaster/squad/.github/actions/squad-init`. + The nested `workflows/aw.yml` is the only supported distribution registration. It isolates package auto-discovery from unrelated repository skills and agents, and installs exactly eight workflows, seventeen runtime resources, and one @@ -226,6 +250,61 @@ that exact file after every `gh aw add`. Keep the verifier requires its exact package bytes. Do not adopt or vendor the rest of gh-aw's generic scaffold. +Revisions whose `workflows/squad-workflows.manifest.json` declares +`"package": "github/squad/workflows"` verify that identity and the legacy +`bradygaster/squad/workflows` identity. Earlier revisions, including ones +committed after the repository moved to `github/squad`, declare and verify only +`bradygaster/squad/workflows`, and installations made from them carry it in +their ownership record, workflow `source:` lines, and locks. Upgrade such an +installation to a revision that declares `github/squad/workflows` with the block +below. gh-aw keeps the superseded +`.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json` after the +forced reinstall, so the block deletes exactly that file before verifying; the +verifier fails closed while both ownership records exist. That first upgrade +also makes `gh aw add` report one safe-update change: action +`github/squad/.github/actions/squad-init` added and +`bradygaster/squad/.github/actions/squad-init` removed. It is the same +squad-init action under the repository's new owner; list it in the PR's +security review note. **STOP** on any other new secret or action. Until an +installation moves, `--verify-install` still passes and prints a notice naming +the legacy identity. To stay on a revision that declares +`bradygaster/squad/workflows`, replace the identity check and the `gh aw add` +line below with `gh aw add "bradygaster/squad/workflows@${SQUAD_SHA}" --force`, +and skip the second `rm -f`: that ownership record is the only one such an +installation has. + +```bash +: "${SQUAD_SHA:?STOP: set SQUAD_SHA to an explicit, maintainer-approved 40-character Squad commit SHA before upgrading.}" +[[ "${SQUAD_SHA}" =~ ^[0-9a-f]{40}$ ]] || { + echo "STOP: SQUAD_SHA must be the exact 40-character commit SHA, not a branch name or shortened hash." >&2 + exit 1 +} +squad_package="$(gh api --hostname github.com -H 'Accept: application/vnd.github.raw+json' \ + "repos/github/squad/contents/workflows/squad-workflows.manifest.json?ref=${SQUAD_SHA}" \ + --jq '.package')" || squad_package="" +test "${squad_package}" = "github/squad/workflows" || { + if [ -z "${squad_package}" ]; then + echo "STOP: cannot read workflows/squad-workflows.manifest.json from github/squad at ${SQUAD_SHA}." >&2 + echo "Check gh auth status --hostname github.com and network access, and confirm SQUAD_SHA is a github/squad commit that has this manifest, then retry." >&2 + else + echo "STOP: Squad ${SQUAD_SHA} does not declare the github/squad/workflows package (found '${squad_package}')." >&2 + echo "Install a revision that declares bradygaster/squad/workflows under that identity, or ask the Squad maintainers for a newer revision." >&2 + fi + exit 1 +} +gh aw add "github/squad/workflows@${SQUAD_SHA}" --force +rm -f .github/skills/agentic-workflows/SKILL.md +rm -f .github/aw/packages/bradygaster-squad-workflows-3632054824e8.json +node .github/workflows/shared/squad-install-verifier.mjs --materialize-runtime +gh aw compile --strict +node .github/workflows/shared/squad-install-verifier.mjs \ + --verify-install \ + --source-revision "${SQUAD_SHA}" \ + --strict-compile +``` + +Apply step 5's two-warning check to the compile output above, then stage and verify as in step 7. + Report/proposal-only is the default. Ordinary fixes require the explicit `"squadRetroAutoImplement": "allow"` setting in `.squad/config.json`; five action issues and three dispatches per wake-up remain separate caps. @@ -247,9 +326,13 @@ On a clean repo, `gh aw add` reports these expected safe updates and **nothing e - Restricted secrets: **`SQUAD_GITHUB_APP_PRIVATE_KEY`** and **`SQUAD_GITHUB_TOKEN`** -- Action: **`bradygaster/squad/.github/actions/squad-init`** +- Action: **`github/squad/.github/actions/squad-init`** +At a revision whose manifest declares `bradygaster/squad/workflows`, the same +action is reported as `bradygaster/squad/.github/actions/squad-init`. Accept +that name only for such a revision. + > **These are referenced names, not prerequisites.** `gh aw add` lists the secrets > the workflows *reference* so you can approve that surface — it is not asking you > to supply them. Both secrets are optional, they need not exist, and neither is @@ -349,7 +432,8 @@ node .github/workflows/shared/squad-install-verifier.mjs \ git diff --cached --stat unexpected_deletions="$( git diff --cached --diff-filter=D --name-only | - grep -vxF '.github/skills/agentic-workflows/SKILL.md' || true + grep -vxF -e '.github/skills/agentic-workflows/SKILL.md' \ + -e '.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json' || true )" test -z "${unexpected_deletions}" || { printf 'STOP: unexpected staged deletions:\n%s\n' "${unexpected_deletions}" >&2 @@ -357,9 +441,11 @@ test -z "${unexpected_deletions}" || { } ``` -- The only permitted staged deletion is +- The only permitted staged deletions are `.github/skills/agentic-workflows/SKILL.md`, when upgrading a repository that - previously committed gh-aw's mutable router. + previously committed gh-aw's mutable router, and + `.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json`, when moving + an existing installation off the legacy `bradygaster/squad/workflows` identity. - **STOP** if the staged diff shows any other **unexpected deletions**, **unexpected secrets**, edits to **unrelated files**, or committed **log/diagnostic output**. Re-scope with explicit `git add -- ` — never `git add .`, `git add -A`, or `git commit -a`. @@ -498,7 +584,20 @@ git switch -c chore/squad-gh-aw-bootstrap echo "STOP: SQUAD_SHA must be an explicit, maintainer-approved 40-character lowercase hex commit SHA." >&2 exit 1 } -gh aw add "bradygaster/squad/workflows@${SQUAD_SHA}" +squad_package="$(gh api --hostname github.com -H 'Accept: application/vnd.github.raw+json' \ + "repos/github/squad/contents/workflows/squad-workflows.manifest.json?ref=${SQUAD_SHA}" \ + --jq '.package')" || squad_package="" +test "${squad_package}" = "github/squad/workflows" || { + if [ -z "${squad_package}" ]; then + echo "STOP: cannot read workflows/squad-workflows.manifest.json from github/squad at ${SQUAD_SHA}." >&2 + echo "Check gh auth status --hostname github.com and network access, and confirm SQUAD_SHA is a github/squad commit that has this manifest, then retry." >&2 + else + echo "STOP: Squad ${SQUAD_SHA} does not declare the github/squad/workflows package (found '${squad_package}')." >&2 + echo "Install a revision that declares bradygaster/squad/workflows under that identity, or ask the Squad maintainers for a newer revision." >&2 + fi + exit 1 +} +gh aw add "github/squad/workflows@${SQUAD_SHA}" rm -f .github/skills/agentic-workflows/SKILL.md # Safe-update report shows ONLY the two documented secrets + squad-init → approve once @@ -566,7 +665,9 @@ gh pr merge --squash # auto-merge before human review. NEVER. `.github/skills/agentic-workflows/SKILL.md`; keep the exact Squad-owned `.github/skills/gh-aw-enlistment/SKILL.md`. - ❌ **Approving unknown safe updates.** Approve ONLY `SQUAD_GITHUB_APP_PRIVATE_KEY`, - `SQUAD_GITHUB_TOKEN`, and `bradygaster/squad/.github/actions/squad-init`. Anything + `SQUAD_GITHUB_TOKEN`, and `github/squad/.github/actions/squad-init` (reported + as `bradygaster/squad/.github/actions/squad-init` only at a revision whose + manifest declares `bradygaster/squad/workflows`). Anything else is a STOP. - ❌ **Treating `--approve` as the final compile.** Always finish with a plain `gh aw compile --strict` (no `--approve`). diff --git a/packages/squad-sdk/templates/skills/gh-aw-enlistment/SKILL.md b/packages/squad-sdk/templates/skills/gh-aw-enlistment/SKILL.md index 0a02b6d01..91043f57a 100644 --- a/packages/squad-sdk/templates/skills/gh-aw-enlistment/SKILL.md +++ b/packages/squad-sdk/templates/skills/gh-aw-enlistment/SKILL.md @@ -25,7 +25,7 @@ bootstrap pull request** — never an auto-merge. This skill **operationalizes** the supported bootstrap path. It does not summarize it: each step below is a gate with explicit success evidence and **STOP conditions**. The authoritative source is `docs/src/content/docs/guide/gh-aw.md` (mirrored at -`https://bradygaster.github.io/squad/docs/guide/gh-aw/`). If that guide and this +`https://github.github.com/squad/docs/guide/gh-aw/`). If that guide and this skill ever disagree, the guide wins — re-read it before proceeding. GitHub Issues are a hard prerequisite: `/squad` commands arrive as issue @@ -193,10 +193,34 @@ published release guidance, then set it once: echo "STOP: SQUAD_SHA must be the exact 40-character commit SHA, not a branch name or shortened hash." >&2 exit 1 } -gh aw add "bradygaster/squad/workflows@${SQUAD_SHA}" +# squad-package-identity-start +squad_package="$(gh api --hostname github.com -H 'Accept: application/vnd.github.raw+json' \ + "repos/github/squad/contents/workflows/squad-workflows.manifest.json?ref=${SQUAD_SHA}" \ + --jq '.package')" || squad_package="" +test "${squad_package}" = "github/squad/workflows" || { + if [ -z "${squad_package}" ]; then + echo "STOP: cannot read workflows/squad-workflows.manifest.json from github/squad at ${SQUAD_SHA}." >&2 + echo "Check gh auth status --hostname github.com and network access, and confirm SQUAD_SHA is a github/squad commit that has this manifest, then retry." >&2 + else + echo "STOP: Squad ${SQUAD_SHA} does not declare the github/squad/workflows package (found '${squad_package}')." >&2 + echo "Install a revision that declares bradygaster/squad/workflows under that identity, or ask the Squad maintainers for a newer revision." >&2 + fi + exit 1 +} +# squad-package-identity-end +gh aw add "github/squad/workflows@${SQUAD_SHA}" rm -f .github/skills/agentic-workflows/SKILL.md ``` +`github/squad/workflows` verifies only at Squad revisions whose +`workflows/squad-workflows.manifest.json` declares +`"package": "github/squad/workflows"`. The identity check above reads that +manifest at `SQUAD_SHA` and stops on any other revision. An older +maintainer-approved revision declares `bradygaster/squad/workflows`: install it +under that identity, because its verifier accepts no other, or ask the Squad +maintainers for a newer revision. Such a revision's safe-update report names +the same squad-init action as `bradygaster/squad/.github/actions/squad-init`. + The nested `workflows/aw.yml` is the only supported distribution registration. It isolates package auto-discovery from unrelated repository skills and agents, and installs exactly eight workflows, seventeen runtime resources, and one @@ -226,6 +250,61 @@ that exact file after every `gh aw add`. Keep the verifier requires its exact package bytes. Do not adopt or vendor the rest of gh-aw's generic scaffold. +Revisions whose `workflows/squad-workflows.manifest.json` declares +`"package": "github/squad/workflows"` verify that identity and the legacy +`bradygaster/squad/workflows` identity. Earlier revisions, including ones +committed after the repository moved to `github/squad`, declare and verify only +`bradygaster/squad/workflows`, and installations made from them carry it in +their ownership record, workflow `source:` lines, and locks. Upgrade such an +installation to a revision that declares `github/squad/workflows` with the block +below. gh-aw keeps the superseded +`.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json` after the +forced reinstall, so the block deletes exactly that file before verifying; the +verifier fails closed while both ownership records exist. That first upgrade +also makes `gh aw add` report one safe-update change: action +`github/squad/.github/actions/squad-init` added and +`bradygaster/squad/.github/actions/squad-init` removed. It is the same +squad-init action under the repository's new owner; list it in the PR's +security review note. **STOP** on any other new secret or action. Until an +installation moves, `--verify-install` still passes and prints a notice naming +the legacy identity. To stay on a revision that declares +`bradygaster/squad/workflows`, replace the identity check and the `gh aw add` +line below with `gh aw add "bradygaster/squad/workflows@${SQUAD_SHA}" --force`, +and skip the second `rm -f`: that ownership record is the only one such an +installation has. + +```bash +: "${SQUAD_SHA:?STOP: set SQUAD_SHA to an explicit, maintainer-approved 40-character Squad commit SHA before upgrading.}" +[[ "${SQUAD_SHA}" =~ ^[0-9a-f]{40}$ ]] || { + echo "STOP: SQUAD_SHA must be the exact 40-character commit SHA, not a branch name or shortened hash." >&2 + exit 1 +} +squad_package="$(gh api --hostname github.com -H 'Accept: application/vnd.github.raw+json' \ + "repos/github/squad/contents/workflows/squad-workflows.manifest.json?ref=${SQUAD_SHA}" \ + --jq '.package')" || squad_package="" +test "${squad_package}" = "github/squad/workflows" || { + if [ -z "${squad_package}" ]; then + echo "STOP: cannot read workflows/squad-workflows.manifest.json from github/squad at ${SQUAD_SHA}." >&2 + echo "Check gh auth status --hostname github.com and network access, and confirm SQUAD_SHA is a github/squad commit that has this manifest, then retry." >&2 + else + echo "STOP: Squad ${SQUAD_SHA} does not declare the github/squad/workflows package (found '${squad_package}')." >&2 + echo "Install a revision that declares bradygaster/squad/workflows under that identity, or ask the Squad maintainers for a newer revision." >&2 + fi + exit 1 +} +gh aw add "github/squad/workflows@${SQUAD_SHA}" --force +rm -f .github/skills/agentic-workflows/SKILL.md +rm -f .github/aw/packages/bradygaster-squad-workflows-3632054824e8.json +node .github/workflows/shared/squad-install-verifier.mjs --materialize-runtime +gh aw compile --strict +node .github/workflows/shared/squad-install-verifier.mjs \ + --verify-install \ + --source-revision "${SQUAD_SHA}" \ + --strict-compile +``` + +Apply step 5's two-warning check to the compile output above, then stage and verify as in step 7. + Report/proposal-only is the default. Ordinary fixes require the explicit `"squadRetroAutoImplement": "allow"` setting in `.squad/config.json`; five action issues and three dispatches per wake-up remain separate caps. @@ -247,9 +326,13 @@ On a clean repo, `gh aw add` reports these expected safe updates and **nothing e - Restricted secrets: **`SQUAD_GITHUB_APP_PRIVATE_KEY`** and **`SQUAD_GITHUB_TOKEN`** -- Action: **`bradygaster/squad/.github/actions/squad-init`** +- Action: **`github/squad/.github/actions/squad-init`** +At a revision whose manifest declares `bradygaster/squad/workflows`, the same +action is reported as `bradygaster/squad/.github/actions/squad-init`. Accept +that name only for such a revision. + > **These are referenced names, not prerequisites.** `gh aw add` lists the secrets > the workflows *reference* so you can approve that surface — it is not asking you > to supply them. Both secrets are optional, they need not exist, and neither is @@ -349,7 +432,8 @@ node .github/workflows/shared/squad-install-verifier.mjs \ git diff --cached --stat unexpected_deletions="$( git diff --cached --diff-filter=D --name-only | - grep -vxF '.github/skills/agentic-workflows/SKILL.md' || true + grep -vxF -e '.github/skills/agentic-workflows/SKILL.md' \ + -e '.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json' || true )" test -z "${unexpected_deletions}" || { printf 'STOP: unexpected staged deletions:\n%s\n' "${unexpected_deletions}" >&2 @@ -357,9 +441,11 @@ test -z "${unexpected_deletions}" || { } ``` -- The only permitted staged deletion is +- The only permitted staged deletions are `.github/skills/agentic-workflows/SKILL.md`, when upgrading a repository that - previously committed gh-aw's mutable router. + previously committed gh-aw's mutable router, and + `.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json`, when moving + an existing installation off the legacy `bradygaster/squad/workflows` identity. - **STOP** if the staged diff shows any other **unexpected deletions**, **unexpected secrets**, edits to **unrelated files**, or committed **log/diagnostic output**. Re-scope with explicit `git add -- ` — never `git add .`, `git add -A`, or `git commit -a`. @@ -498,7 +584,20 @@ git switch -c chore/squad-gh-aw-bootstrap echo "STOP: SQUAD_SHA must be an explicit, maintainer-approved 40-character lowercase hex commit SHA." >&2 exit 1 } -gh aw add "bradygaster/squad/workflows@${SQUAD_SHA}" +squad_package="$(gh api --hostname github.com -H 'Accept: application/vnd.github.raw+json' \ + "repos/github/squad/contents/workflows/squad-workflows.manifest.json?ref=${SQUAD_SHA}" \ + --jq '.package')" || squad_package="" +test "${squad_package}" = "github/squad/workflows" || { + if [ -z "${squad_package}" ]; then + echo "STOP: cannot read workflows/squad-workflows.manifest.json from github/squad at ${SQUAD_SHA}." >&2 + echo "Check gh auth status --hostname github.com and network access, and confirm SQUAD_SHA is a github/squad commit that has this manifest, then retry." >&2 + else + echo "STOP: Squad ${SQUAD_SHA} does not declare the github/squad/workflows package (found '${squad_package}')." >&2 + echo "Install a revision that declares bradygaster/squad/workflows under that identity, or ask the Squad maintainers for a newer revision." >&2 + fi + exit 1 +} +gh aw add "github/squad/workflows@${SQUAD_SHA}" rm -f .github/skills/agentic-workflows/SKILL.md # Safe-update report shows ONLY the two documented secrets + squad-init → approve once @@ -566,7 +665,9 @@ gh pr merge --squash # auto-merge before human review. NEVER. `.github/skills/agentic-workflows/SKILL.md`; keep the exact Squad-owned `.github/skills/gh-aw-enlistment/SKILL.md`. - ❌ **Approving unknown safe updates.** Approve ONLY `SQUAD_GITHUB_APP_PRIVATE_KEY`, - `SQUAD_GITHUB_TOKEN`, and `bradygaster/squad/.github/actions/squad-init`. Anything + `SQUAD_GITHUB_TOKEN`, and `github/squad/.github/actions/squad-init` (reported + as `bradygaster/squad/.github/actions/squad-init` only at a revision whose + manifest declares `bradygaster/squad/workflows`). Anything else is a STOP. - ❌ **Treating `--approve` as the final compile.** Always finish with a plain `gh aw compile --strict` (no `--approve`). diff --git a/scripts/gh-aw-hosted-e2e.mjs b/scripts/gh-aw-hosted-e2e.mjs index dce37b8bc..e60c75996 100644 --- a/scripts/gh-aw-hosted-e2e.mjs +++ b/scripts/gh-aw-hosted-e2e.mjs @@ -15,6 +15,7 @@ import { } from './gh-aw-hosted-e2e-contract.mjs'; import { CONTRACT_DESTINATION, + LEGACY_OWNERSHIP_DESTINATIONS, OWNERSHIP_ENTRY_COUNT, OWNERSHIP_DESTINATION, PACKAGE_NAME, @@ -24,11 +25,11 @@ import { verifyStagedInstall, } from '../workflows/shared/squad-install-verifier.mjs'; -const TRUSTED_SOURCE = Object.freeze({ - repository: 'bradygaster/squad', +export const TRUSTED_SOURCE = Object.freeze({ + repository: 'github/squad', repositoryId: 1151205052, - owner: 'bradygaster', - ownerId: 41929050, + owner: 'github', + ownerId: 9919, }); const SHA_PATTERN = /^[0-9a-f]{40}$/; const INSTALL_PR_TITLE = 'ci: install Squad agentic workflows'; @@ -292,7 +293,7 @@ function packageArtifactCategories(contract) { runtimePackageDestinations: contract.runtime.map(({ packageDestination }) => packageDestination), runtimeDestinations: contract.runtime.map(({ destination }) => destination), manifest: [CONTRACT_DESTINATION], - ownership: [OWNERSHIP_DESTINATION], + ownership: [OWNERSHIP_DESTINATION, ...LEGACY_OWNERSHIP_DESTINATIONS], skill: contract.skills.map(({ destination }) => destination), triggerSentinel: [TRIGGER_PROBE_DESTINATION], }; @@ -954,6 +955,9 @@ function verifyInstalledTrusted(repositoryRoot, checkout, sourceSha) { assertInstalledManifestIdentity(repositoryRoot, checkout); const result = verifyInstall(checkout, { expectedRevision: sourceSha }); if (result.failures.length > 0) throw new Error(result.failures.join('\n')); + if (result.packageName !== PACKAGE_NAME) { + throw new Error(`Hosted install must be owned by ${PACKAGE_NAME}; found ${result.packageName}.`); + } const ownership = JSON.parse(readFileSync( resolve(checkout, OWNERSHIP_DESTINATION), 'utf8', @@ -1226,9 +1230,9 @@ function hosted(args, repositoryRoot) { // Actions workflow or repository_dispatch job, and must never be run with a repository secret. // Run it from your own workstation, authenticated as yourself (`gh auth login`); `--evidence-in` // is a directory you populate yourself first, e.g. via -// `gh run download --repo bradygaster/squad \ +// `gh run download --repo github/squad \ // --name squad-gh-aw-hosted-e2e- --dir ` -// against the bradygaster/squad repository you already have read access to. Whatever GH_TOKEN is +// against the github/squad repository you already have read access to. Whatever GH_TOKEN is // present in your shell when you run this (your own personal token, never a repo secret) is what // it uses to observe the target repository. // diff --git a/templates/skills/gh-aw-enlistment/SKILL.md b/templates/skills/gh-aw-enlistment/SKILL.md index 0a02b6d01..91043f57a 100644 --- a/templates/skills/gh-aw-enlistment/SKILL.md +++ b/templates/skills/gh-aw-enlistment/SKILL.md @@ -25,7 +25,7 @@ bootstrap pull request** — never an auto-merge. This skill **operationalizes** the supported bootstrap path. It does not summarize it: each step below is a gate with explicit success evidence and **STOP conditions**. The authoritative source is `docs/src/content/docs/guide/gh-aw.md` (mirrored at -`https://bradygaster.github.io/squad/docs/guide/gh-aw/`). If that guide and this +`https://github.github.com/squad/docs/guide/gh-aw/`). If that guide and this skill ever disagree, the guide wins — re-read it before proceeding. GitHub Issues are a hard prerequisite: `/squad` commands arrive as issue @@ -193,10 +193,34 @@ published release guidance, then set it once: echo "STOP: SQUAD_SHA must be the exact 40-character commit SHA, not a branch name or shortened hash." >&2 exit 1 } -gh aw add "bradygaster/squad/workflows@${SQUAD_SHA}" +# squad-package-identity-start +squad_package="$(gh api --hostname github.com -H 'Accept: application/vnd.github.raw+json' \ + "repos/github/squad/contents/workflows/squad-workflows.manifest.json?ref=${SQUAD_SHA}" \ + --jq '.package')" || squad_package="" +test "${squad_package}" = "github/squad/workflows" || { + if [ -z "${squad_package}" ]; then + echo "STOP: cannot read workflows/squad-workflows.manifest.json from github/squad at ${SQUAD_SHA}." >&2 + echo "Check gh auth status --hostname github.com and network access, and confirm SQUAD_SHA is a github/squad commit that has this manifest, then retry." >&2 + else + echo "STOP: Squad ${SQUAD_SHA} does not declare the github/squad/workflows package (found '${squad_package}')." >&2 + echo "Install a revision that declares bradygaster/squad/workflows under that identity, or ask the Squad maintainers for a newer revision." >&2 + fi + exit 1 +} +# squad-package-identity-end +gh aw add "github/squad/workflows@${SQUAD_SHA}" rm -f .github/skills/agentic-workflows/SKILL.md ``` +`github/squad/workflows` verifies only at Squad revisions whose +`workflows/squad-workflows.manifest.json` declares +`"package": "github/squad/workflows"`. The identity check above reads that +manifest at `SQUAD_SHA` and stops on any other revision. An older +maintainer-approved revision declares `bradygaster/squad/workflows`: install it +under that identity, because its verifier accepts no other, or ask the Squad +maintainers for a newer revision. Such a revision's safe-update report names +the same squad-init action as `bradygaster/squad/.github/actions/squad-init`. + The nested `workflows/aw.yml` is the only supported distribution registration. It isolates package auto-discovery from unrelated repository skills and agents, and installs exactly eight workflows, seventeen runtime resources, and one @@ -226,6 +250,61 @@ that exact file after every `gh aw add`. Keep the verifier requires its exact package bytes. Do not adopt or vendor the rest of gh-aw's generic scaffold. +Revisions whose `workflows/squad-workflows.manifest.json` declares +`"package": "github/squad/workflows"` verify that identity and the legacy +`bradygaster/squad/workflows` identity. Earlier revisions, including ones +committed after the repository moved to `github/squad`, declare and verify only +`bradygaster/squad/workflows`, and installations made from them carry it in +their ownership record, workflow `source:` lines, and locks. Upgrade such an +installation to a revision that declares `github/squad/workflows` with the block +below. gh-aw keeps the superseded +`.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json` after the +forced reinstall, so the block deletes exactly that file before verifying; the +verifier fails closed while both ownership records exist. That first upgrade +also makes `gh aw add` report one safe-update change: action +`github/squad/.github/actions/squad-init` added and +`bradygaster/squad/.github/actions/squad-init` removed. It is the same +squad-init action under the repository's new owner; list it in the PR's +security review note. **STOP** on any other new secret or action. Until an +installation moves, `--verify-install` still passes and prints a notice naming +the legacy identity. To stay on a revision that declares +`bradygaster/squad/workflows`, replace the identity check and the `gh aw add` +line below with `gh aw add "bradygaster/squad/workflows@${SQUAD_SHA}" --force`, +and skip the second `rm -f`: that ownership record is the only one such an +installation has. + +```bash +: "${SQUAD_SHA:?STOP: set SQUAD_SHA to an explicit, maintainer-approved 40-character Squad commit SHA before upgrading.}" +[[ "${SQUAD_SHA}" =~ ^[0-9a-f]{40}$ ]] || { + echo "STOP: SQUAD_SHA must be the exact 40-character commit SHA, not a branch name or shortened hash." >&2 + exit 1 +} +squad_package="$(gh api --hostname github.com -H 'Accept: application/vnd.github.raw+json' \ + "repos/github/squad/contents/workflows/squad-workflows.manifest.json?ref=${SQUAD_SHA}" \ + --jq '.package')" || squad_package="" +test "${squad_package}" = "github/squad/workflows" || { + if [ -z "${squad_package}" ]; then + echo "STOP: cannot read workflows/squad-workflows.manifest.json from github/squad at ${SQUAD_SHA}." >&2 + echo "Check gh auth status --hostname github.com and network access, and confirm SQUAD_SHA is a github/squad commit that has this manifest, then retry." >&2 + else + echo "STOP: Squad ${SQUAD_SHA} does not declare the github/squad/workflows package (found '${squad_package}')." >&2 + echo "Install a revision that declares bradygaster/squad/workflows under that identity, or ask the Squad maintainers for a newer revision." >&2 + fi + exit 1 +} +gh aw add "github/squad/workflows@${SQUAD_SHA}" --force +rm -f .github/skills/agentic-workflows/SKILL.md +rm -f .github/aw/packages/bradygaster-squad-workflows-3632054824e8.json +node .github/workflows/shared/squad-install-verifier.mjs --materialize-runtime +gh aw compile --strict +node .github/workflows/shared/squad-install-verifier.mjs \ + --verify-install \ + --source-revision "${SQUAD_SHA}" \ + --strict-compile +``` + +Apply step 5's two-warning check to the compile output above, then stage and verify as in step 7. + Report/proposal-only is the default. Ordinary fixes require the explicit `"squadRetroAutoImplement": "allow"` setting in `.squad/config.json`; five action issues and three dispatches per wake-up remain separate caps. @@ -247,9 +326,13 @@ On a clean repo, `gh aw add` reports these expected safe updates and **nothing e - Restricted secrets: **`SQUAD_GITHUB_APP_PRIVATE_KEY`** and **`SQUAD_GITHUB_TOKEN`** -- Action: **`bradygaster/squad/.github/actions/squad-init`** +- Action: **`github/squad/.github/actions/squad-init`** +At a revision whose manifest declares `bradygaster/squad/workflows`, the same +action is reported as `bradygaster/squad/.github/actions/squad-init`. Accept +that name only for such a revision. + > **These are referenced names, not prerequisites.** `gh aw add` lists the secrets > the workflows *reference* so you can approve that surface — it is not asking you > to supply them. Both secrets are optional, they need not exist, and neither is @@ -349,7 +432,8 @@ node .github/workflows/shared/squad-install-verifier.mjs \ git diff --cached --stat unexpected_deletions="$( git diff --cached --diff-filter=D --name-only | - grep -vxF '.github/skills/agentic-workflows/SKILL.md' || true + grep -vxF -e '.github/skills/agentic-workflows/SKILL.md' \ + -e '.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json' || true )" test -z "${unexpected_deletions}" || { printf 'STOP: unexpected staged deletions:\n%s\n' "${unexpected_deletions}" >&2 @@ -357,9 +441,11 @@ test -z "${unexpected_deletions}" || { } ``` -- The only permitted staged deletion is +- The only permitted staged deletions are `.github/skills/agentic-workflows/SKILL.md`, when upgrading a repository that - previously committed gh-aw's mutable router. + previously committed gh-aw's mutable router, and + `.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json`, when moving + an existing installation off the legacy `bradygaster/squad/workflows` identity. - **STOP** if the staged diff shows any other **unexpected deletions**, **unexpected secrets**, edits to **unrelated files**, or committed **log/diagnostic output**. Re-scope with explicit `git add -- ` — never `git add .`, `git add -A`, or `git commit -a`. @@ -498,7 +584,20 @@ git switch -c chore/squad-gh-aw-bootstrap echo "STOP: SQUAD_SHA must be an explicit, maintainer-approved 40-character lowercase hex commit SHA." >&2 exit 1 } -gh aw add "bradygaster/squad/workflows@${SQUAD_SHA}" +squad_package="$(gh api --hostname github.com -H 'Accept: application/vnd.github.raw+json' \ + "repos/github/squad/contents/workflows/squad-workflows.manifest.json?ref=${SQUAD_SHA}" \ + --jq '.package')" || squad_package="" +test "${squad_package}" = "github/squad/workflows" || { + if [ -z "${squad_package}" ]; then + echo "STOP: cannot read workflows/squad-workflows.manifest.json from github/squad at ${SQUAD_SHA}." >&2 + echo "Check gh auth status --hostname github.com and network access, and confirm SQUAD_SHA is a github/squad commit that has this manifest, then retry." >&2 + else + echo "STOP: Squad ${SQUAD_SHA} does not declare the github/squad/workflows package (found '${squad_package}')." >&2 + echo "Install a revision that declares bradygaster/squad/workflows under that identity, or ask the Squad maintainers for a newer revision." >&2 + fi + exit 1 +} +gh aw add "github/squad/workflows@${SQUAD_SHA}" rm -f .github/skills/agentic-workflows/SKILL.md # Safe-update report shows ONLY the two documented secrets + squad-init → approve once @@ -566,7 +665,9 @@ gh pr merge --squash # auto-merge before human review. NEVER. `.github/skills/agentic-workflows/SKILL.md`; keep the exact Squad-owned `.github/skills/gh-aw-enlistment/SKILL.md`. - ❌ **Approving unknown safe updates.** Approve ONLY `SQUAD_GITHUB_APP_PRIVATE_KEY`, - `SQUAD_GITHUB_TOKEN`, and `bradygaster/squad/.github/actions/squad-init`. Anything + `SQUAD_GITHUB_TOKEN`, and `github/squad/.github/actions/squad-init` (reported + as `bradygaster/squad/.github/actions/squad-init` only at a revision whose + manifest declares `bradygaster/squad/workflows`). Anything else is a STOP. - ❌ **Treating `--approve` as the final compile.** Always finish with a plain `gh aw compile --strict` (no `--approve`). diff --git a/test/fixtures/squad-init-approved.json b/test/fixtures/squad-init-approved.json index e8a33d2d7..8cda7274b 100644 --- a/test/fixtures/squad-init-approved.json +++ b/test/fixtures/squad-init-approved.json @@ -1,5 +1,5 @@ { - "repository": "bradygaster/squad", + "repository": "github/squad", "revision": "5c662ba015ec4f99befa51b0e8ca4f2148b0497f", "files": { ".github/actions/squad-init/action.yml": { diff --git a/test/gh-aw-enlistment-skill.test.ts b/test/gh-aw-enlistment-skill.test.ts index 7d9ef7291..79721cb82 100644 --- a/test/gh-aw-enlistment-skill.test.ts +++ b/test/gh-aw-enlistment-skill.test.ts @@ -19,6 +19,13 @@ import { spawnSync } from 'node:child_process'; import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { parseSkillFile } from '@bradygaster/squad-sdk/skills'; +import { + LEGACY_OWNERSHIP_DESTINATIONS, + LEGACY_PACKAGE_NAMES, + OWNERSHIP_DESTINATION, + PACKAGE_NAME, + UNOWNED_MUTABLE_ROUTER_SKILL, +} from '../workflows/shared/squad-install-verifier.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(__dirname, '..'); @@ -35,6 +42,84 @@ const MIRRORS = [ `packages/squad-sdk/templates/skills/${SKILL_ID}/SKILL.md`, ] as const; +// Public install commands are bound to the verifier's canonical package identity. +const INSTALL_COMMAND = `gh aw add "${PACKAGE_NAME}@\${SQUAD_SHA}"`; +const [LEGACY_PACKAGE_NAME] = LEGACY_PACKAGE_NAMES; +const [LEGACY_OWNERSHIP] = LEGACY_OWNERSHIP_DESTINATIONS; +const SQUAD_INIT_ACTION = `${PACKAGE_NAME.replace(/\/workflows$/, '')}/.github/actions/squad-init`; +const DELETION_FILTER = [ + ` grep -vxF -e '${UNOWNED_MUTABLE_ROUTER_SKILL}' \\`, + ` -e '${LEGACY_OWNERSHIP}' || true`, +].join('\n'); + +function extractDeletionGate(content: string): string { + const start = content.indexOf('unexpected_deletions="$('); + const end = content.indexOf('\n}', content.indexOf('test -z "${unexpected_deletions}"', start)); + expect(start, 'staged-deletion gate must exist').toBeGreaterThan(-1); + expect(end, 'staged-deletion gate must end with its STOP block').toBeGreaterThan(start); + return content.slice(start, end + 2); +} + +function runDeletionGate(gate: string, deletions: readonly string[]) { + const script = ` +set -euo pipefail +git() { printf '%s' "$STAGED_DELETIONS"; } +${gate} +`; + return spawnSync('bash', ['-c', script], { + encoding: 'utf8', + env: { ...process.env, STAGED_DELETIONS: deletions.map(path => `${path}\n`).join('') }, + }); +} + +const IDENTITY_GATE_START = '# squad-package-identity-start'; +const IDENTITY_GATE_END = '# squad-package-identity-end'; +const IDENTITY_SHA = 'c'.repeat(40); +const LEGACY_INSTALL_PREFIX = `gh aw add "${LEGACY_PACKAGE_NAME}@`; +const MANIFEST_RULE = `\`${PACKAGE_NAME}\` verifies only at Squad revisions whose ` + + `\`workflows/squad-workflows.manifest.json\` declares \`"package": "${PACKAGE_NAME}"\``; +const IDENTITY_LEGACY_HINT = `Install a revision that declares ${LEGACY_PACKAGE_NAME} under that identity, ` + + 'or ask the Squad maintainers for a newer revision.'; +const IDENTITY_UNREADABLE_STOP = 'STOP: cannot read workflows/squad-workflows.manifest.json from github/squad ' + + `at ${IDENTITY_SHA}.`; +const IDENTITY_UNREADABLE_HINT = 'Check gh auth status --hostname github.com and network access, ' + + 'and confirm SQUAD_SHA is a github/squad commit that has this manifest, then retry.'; + +function extractIdentityGate(content: string): string { + const start = content.indexOf(IDENTITY_GATE_START); + const end = content.indexOf(IDENTITY_GATE_END); + expect(start, `${IDENTITY_GATE_START} must exist`).toBeGreaterThan(-1); + expect(content.indexOf(IDENTITY_GATE_START, start + 1), 'one marked identity gate per document').toBe(-1); + expect(end, `${IDENTITY_GATE_END} must follow its start marker`).toBeGreaterThan(start); + return content.slice(start + IDENTITY_GATE_START.length + 1, end); +} + +function runIdentityGate(gate: string, response: { manifestPackage?: string; fail?: boolean }) { + const script = ` +set -euo pipefail +exec 3>&2 +gh() { + printf 'CALL %s\\n' "$*" >&3 + if [ "$GH_FAIL" = "1" ]; then + printf '{"message":"No commit found for the ref"}\\n' + return 1 + fi + printf '%s\\n' "$GH_PACKAGE" +} +SQUAD_SHA="${IDENTITY_SHA}" +${gate} +echo "IDENTITY GATE PASSED" +`; + return spawnSync('bash', ['-c', script], { + encoding: 'utf8', + env: { ...process.env, GH_PACKAGE: response.manifestPackage ?? '', GH_FAIL: response.fail ? '1' : '0' }, + }); +} + +function fencedBlocks(text: string): string[] { + return [...text.matchAll(/^```[^\n]*\n([\s\S]*?)^```$/gm)].map(match => match[1]); +} + function readRaw(rel: string): string { return readFileSync(resolve(ROOT, rel), 'utf-8'); } @@ -236,7 +321,7 @@ describe('gh-aw-enlistment skill', () => { [ 'SQUAD_GITHUB_APP_PRIVATE_KEY', 'SQUAD_GITHUB_TOKEN', - 'bradygaster/squad/.github/actions/squad-init', + SQUAD_INIT_ACTION, ].sort() ); @@ -249,7 +334,11 @@ describe('gh-aw-enlistment skill', () => { expect(content).toContain(': "${SQUAD_SHA:?STOP: set SQUAD_SHA to an explicit, maintainer-approved 40-character Squad commit SHA before installing.}"'); expect(content).not.toContain('commits/dev'); expect(content).toContain('^' + '[0-9a-f]{40}' + '$'); - expect(content).toContain('gh aw add "bradygaster/squad/workflows@${SQUAD_SHA}"'); + expect(content, `skill must install ${PACKAGE_NAME}`).toContain(INSTALL_COMMAND); + for (const block of fencedBlocks(content)) { + expect(block, `no executable block may install the legacy ${LEGACY_PACKAGE_NAME} identity`) + .not.toContain(LEGACY_INSTALL_PREFIX); + } expect(content).toContain('exactly eight workflows, seventeen runtime resources, and one'); for (const workflow of [ 'squad.md', @@ -273,14 +362,149 @@ describe('gh-aw-enlistment skill', () => { }); it('removes only gh-aw\'s mutable router and preserves the exact Squad skill', () => { - expect(content.match(/rm -f \.github\/skills\/agentic-workflows\/SKILL\.md/g)) - .toHaveLength(2); + expect(content.match(/rm -f \.github\/skills\/agentic-workflows\/SKILL\.md/g), + 'install, upgrade and correct-example blocks each remove the router').toHaveLength(3); expect(content).toContain('not part of the Squad package'); expect(content).toContain('Do not adopt or vendor the rest'); expect(content).toContain('.github/skills/gh-aw-enlistment/SKILL.md'); - expect(content).toContain( - "grep -vxF '.github/skills/agentic-workflows/SKILL.md' || true", + expect(content).toContain(DELETION_FILTER); + }); + + it.each([ + ['no deletions', [], 0], + ['the mutable router', [UNOWNED_MUTABLE_ROUTER_SKILL], 0], + ['the superseded legacy ownership record', [LEGACY_OWNERSHIP], 0], + ['the router and the legacy record together', [UNOWNED_MUTABLE_ROUTER_SKILL, LEGACY_OWNERSHIP], 0], + ['the canonical ownership record', [OWNERSHIP_DESTINATION], 1], + ['an installed workflow', ['.github/workflows/squad.md'], 1], + ['a legacy-record prefix match', [`${LEGACY_OWNERSHIP}.bak`], 1], + ] as const)('the staged-deletion gate handles %s', (label, deletions, status) => { + const result = runDeletionGate(extractDeletionGate(content), deletions); + expect(result.status, `${label} (${deletions.join(', ')}): ${result.stderr}`).toBe(status); + if (status === 1) { + expect(result.stderr).toContain(`STOP: unexpected staged deletions:\n${deletions.join('\n')}`); + } + }); + + it('documents the legacy-identity migration that the verifier enforces', () => { + const flat = content.replace(/\s+/g, ' '); + const why = `skill must document migrating off ${LEGACY_PACKAGE_NAME} and deleting ${LEGACY_OWNERSHIP}`; + expect(flat, why).toContain( + 'Earlier revisions, including ones committed after the repository moved to `github/squad`, ' + + `declare and verify only \`${LEGACY_PACKAGE_NAME}\``, + ); + expect(flat, why).toContain( + `\`${LEGACY_OWNERSHIP}\` after the forced reinstall, so the block deletes exactly that file before verifying`, + ); + expect(flat, why).toContain('the verifier fails closed while both ownership records exist'); + expect(flat, 'skill must name the one-time squad-init safe-update change').toContain( + `action \`${SQUAD_INIT_ACTION}\` added and \`bradygaster/squad/.github/actions/squad-init\` removed.`, + ); + expect(flat, why).toContain('**STOP** on any other new secret or action.'); + expect(flat, 'skill must say how to stay on a legacy-only revision').toContain( + `with \`${LEGACY_INSTALL_PREFIX}\${SQUAD_SHA}" --force\`, and skip the second \`rm -f\`: ` + + 'that ownership record is the only one such an installation has.', + ); + const upgrade = fencedBlocks(content).find(block => block.includes(`${INSTALL_COMMAND} --force`)); + expect(upgrade, 'skill must include an executable upgrade block').toBeDefined(); + const steps = [ + extractIdentityGate(content), + `${INSTALL_COMMAND} --force`, + `rm -f ${UNOWNED_MUTABLE_ROUTER_SKILL}`, + `rm -f ${LEGACY_OWNERSHIP}`, + 'squad-install-verifier.mjs --materialize-runtime', + 'gh aw compile --strict', + '--verify-install', + ]; + const order = steps.map(step => upgrade!.indexOf(step)); + expect(order.every(index => index > -1), `upgrade block step positions: ${order.join(', ')}`).toBe(true); + expect(order, 'upgrade block steps must run in order').toEqual([...order].sort((a, b) => a - b)); + const stepNumber = (title: string) => { + const heading = content.split('\n').find(line => line.startsWith('### ') && line.endsWith(title)); + expect(heading, `skill must keep the step "${title}"`).toBeDefined(); + return /^### (\d+)\. /.exec(heading!)![1]; + }; + const compileStep = stepNumber('Always run the final strict compile WITHOUT `--approve`'); + const stageStep = stepNumber('Inspect generated files, then stage only the documented surfaces'); + const compileSection = content.slice( + content.indexOf(`### ${compileStep}. `), + content.indexOf(`### ${Number(compileStep) + 1}. `), + ); + expect(compileSection.replace(/\s+/g, ' '), `step ${compileStep} must hold the two-warning check`) + .toContain('require exactly two warnings'); + expect(flat, 'the upgrade block must hand off to the warning check and staging steps by their current numbers') + .toContain(`Apply step ${compileStep}'s two-warning check to the compile output above, ` + + `then stage and verify as in step ${stageStep}.`); + }); + + it('gates installation on the revision declaring the canonical package identity', () => { + const gate = extractIdentityGate(content); + expect(content.indexOf(IDENTITY_GATE_END), 'the identity gate must precede the install command') + .toBeLessThan(content.indexOf(INSTALL_COMMAND)); + expect(content.split(gate).length - 1, 'install, upgrade and correct-example blocks must run the same gate') + .toBe(3); + const flat = content.replace(/\s+/g, ' '); + expect(flat, 'skill must state the manifest cutoff').toContain(MANIFEST_RULE); + expect(flat, 'skill must route older revisions').toContain( + `An older maintainer-approved revision declares \`${LEGACY_PACKAGE_NAME}\`: install it under that identity, ` + + 'because its verifier accepts no other, or ask the Squad maintainers for a newer revision.', + ); + }); + + it.each([ + ['a revision declaring the canonical package', { manifestPackage: PACKAGE_NAME }, 'pass', ''], + ['a revision declaring the legacy package', { manifestPackage: LEGACY_PACKAGE_NAME }, 'mismatch', + LEGACY_PACKAGE_NAME], + ['a case-variant package', { manifestPackage: 'GitHub/Squad/workflows' }, 'mismatch', 'GitHub/Squad/workflows'], + ['an unreadable manifest', { fail: true }, 'unreadable', ''], + ] as const)('the identity gate handles %s', (label, response, outcome, found) => { + const result = runIdentityGate(extractIdentityGate(content), response); + expect(result.status, `${label}: ${result.stderr}`).toBe(outcome === 'pass' ? 0 : 1); + expect(result.stderr, `${label}: the gate must read the manifest at SQUAD_SHA on github.com`).toContain( + 'CALL api --hostname github.com -H Accept: application/vnd.github.raw+json ' + + `repos/github/squad/contents/workflows/squad-workflows.manifest.json?ref=${IDENTITY_SHA} --jq .package`, + ); + if (outcome === 'pass') { + expect(result.stdout, label).toContain('IDENTITY GATE PASSED'); + return; + } + expect(result.stdout, `${label} must stop before installing`).not.toContain('IDENTITY GATE PASSED'); + if (outcome === 'unreadable') { + expect(result.stderr, `${label}: the stop must say the manifest could not be read`) + .toContain(IDENTITY_UNREADABLE_STOP); + expect(result.stderr, `${label}: the stop must point at gh auth, network access and SQUAD_SHA`) + .toContain(IDENTITY_UNREADABLE_HINT); + expect(result.stderr, `${label}: an unreadable manifest must not suggest the legacy identity`) + .not.toContain(LEGACY_PACKAGE_NAME); + return; + } + expect(result.stderr, label).toContain( + `STOP: Squad ${IDENTITY_SHA} does not declare the ${PACKAGE_NAME} package (found '${found}').`, ); + expect(result.stderr, `${label}: a readable manifest routes to the legacy identity`) + .toContain(IDENTITY_LEGACY_HINT); + expect(result.stderr, `${label}: a readable manifest is not an access problem`) + .not.toContain(IDENTITY_UNREADABLE_HINT); + }); + + it('names the legacy squad-init action only for revisions that declare the legacy identity', () => { + const flat = content.replace(/\s+/g, ' '); + expect(flat).toContain( + `At a revision whose manifest declares \`${LEGACY_PACKAGE_NAME}\`, the same action is reported as ` + + '`bradygaster/squad/.github/actions/squad-init`. Accept that name only for such a revision.', + ); + }); + + it('dates the identity cutoff by the manifest, not by the repository move', () => { + for (const [path, text] of [ + [CANONICAL, content], + [GUIDE, readLF(GUIDE)], + ['workflows/README.md', readLF('workflows/README.md')], + ['workflows/shared/squad-install-verifier.mjs', readLF('workflows/shared/squad-install-verifier.mjs')], + ] as const) { + expect(text.replace(/\s+/g, ' '), `${path} must not date the identity cutoff by the move`) + .not.toMatch(/before (?:the (?:repository )?move|Squad moved|the repository moved)|pre-move/i); + } }); it('requires a final strict compile without --approve', () => { @@ -318,7 +542,7 @@ describe('gh-aw-enlistment skill', () => { expect(content).toContain('STOP before branch creation or `gh aw add`'); expect(content.indexOf("issues_enabled=")).toBeLessThan(content.indexOf('git switch -c')); expect(content.indexOf("issues_enabled=")).toBeLessThan( - content.indexOf('gh aw add "bradygaster/squad/workflows@${SQUAD_SHA}"'), + content.indexOf(INSTALL_COMMAND), ); }); @@ -429,7 +653,7 @@ describe('gh-aw-enlistment skill', () => { guide.indexOf('git switch -c chore/squad-gh-aw-bootstrap'), ); expect(guide.indexOf(VERSION_GATE_END)).toBeLessThan( - guide.indexOf('gh aw add "bradygaster/squad/workflows@${SQUAD_SHA}"'), + guide.indexOf(INSTALL_COMMAND), ); }); @@ -442,14 +666,74 @@ describe('gh-aw-enlistment skill', () => { .toHaveLength(2); }); - it('removes the mutable gh-aw router before verification and permits only that staged deletion', () => { + it('removes the mutable gh-aw router before verification and permits only the documented staged deletions', () => { expect(guide.match(/rm -f \.github\/skills\/agentic-workflows\/SKILL\.md/g)) .toHaveLength(3); - expect(guide).toContain('The exact router deletion above is the only permitted staged deletion'); + const flatGuide = guide.replace(/\s+/g, ' '); + expect(flatGuide).toContain( + 'The only permitted staged deletions are the exact router deletion above', + ); + expect(flatGuide).toContain(`the superseded \`${LEGACY_OWNERSHIP}\` ownership record`); + expect(flatGuide).toContain('Any other deletion is a hard stop.'); + expect(guide.split(DELETION_FILTER).length - 1, 'both guide staging gates must use the skill filter') + .toBe(2); expect(guide).toContain('.github/skills/gh-aw-enlistment/SKILL.md'); expect(agentGuide).toContain('rm -f .github/skills/agentic-workflows/SKILL.md'); }); + it('advertises only the canonical package identity on every public install surface', () => { + for (const [path, text] of [ + [GUIDE, guide], + [AGENT_GUIDE, agentGuide], + ['README.md', readLF('README.md')], + ] as const) { + expect(text, `${path} must install ${PACKAGE_NAME}`).toContain(INSTALL_COMMAND); + for (const block of fencedBlocks(text)) { + expect(block, `${path}: no executable block may install the legacy ${LEGACY_PACKAGE_NAME} identity`) + .not.toContain(LEGACY_INSTALL_PREFIX); + } + } + expect(guide).toContain(`# (${SQUAD_INIT_ACTION}) and nothing else. STOP and`); + expect(guide).toContain(`- Action: \`${SQUAD_INIT_ACTION}\``); + expect(guide.replace(/\s+/g, ' ')).toContain( + `At a revision whose manifest declares \`${LEGACY_PACKAGE_NAME}\`, the same action is reported as ` + + '`bradygaster/squad/.github/actions/squad-init`.', + ); + expect(guide).toContain(`\`${PACKAGE_NAME}@SHA\` for direct package includes`); + expect(guide).toContain(`\`${PACKAGE_NAME}/package/.md@SHA\` when installing dispatched`); + }); + + it('runs the skill\'s identity gate before every guide and README install command', () => { + const gate = extractIdentityGate(readLF(CANONICAL)); + expect(extractIdentityGate(guide), 'the guide quick-start gate must match the skill').toBe(gate); + const readme = readLF('README.md'); + for (const [path, text, copies] of [[GUIDE, guide, 3], ['README.md', readme, 1]] as const) { + expect(text.split(gate).length - 1, `${path} identity gate copies`).toBe(copies); + const installs = fencedBlocks(text).filter(block => block.includes(INSTALL_COMMAND)); + expect(installs, `${path} install blocks`).toHaveLength(copies); + for (const block of installs) { + expect(block.indexOf(gate), `${path}: every install block must run the identity gate`).toBeGreaterThan(-1); + expect(block.indexOf(gate), `${path}: the identity gate must precede gh aw add`) + .toBeLessThan(block.indexOf(INSTALL_COMMAND)); + } + } + }); + + it('states which revisions verify the canonical identity on every install surface', () => { + for (const [path, text] of [ + [GUIDE, guide], + [AGENT_GUIDE, agentGuide], + ['README.md', readLF('README.md')], + ['workflows/README.md', readLF('workflows/README.md')], + ] as const) { + const flat = text.replace(/\s+/g, ' '); + expect(flat, `${path} must state the manifest cutoff`).toContain(MANIFEST_RULE); + expect(flat, `${path} must route older revisions to the legacy identity`) + .toMatch(/older (?:maintainer-)?approved revision (?:as|under that identity|declares)/); + expect(flat, `${path} must offer a newer revision`).toMatch(/ask the (?:Squad )?maintainers for a newer revision/); + } + }); + it('requires agents to execute the complete quick start through PR creation', () => { expect(guide).toContain('**Agent action required:**'); expect(guide).toContain('the agent must execute this entire block'); @@ -478,7 +762,7 @@ describe('gh-aw-enlistment skill', () => { expect(guide).toContain('do not continue to `gh aw add`'); expect(issuesCheck).toBeLessThan(guide.indexOf('git switch -c chore/squad-gh-aw-bootstrap')); expect(issuesCheck).toBeLessThan( - guide.indexOf('gh aw add "bradygaster/squad/workflows@${SQUAD_SHA}"'), + guide.indexOf(INSTALL_COMMAND), ); expect(agentGuide).toContain('verifies that GitHub Issues are enabled'); expect(agentGuide).toContain('bootstrap creates a research/proposals issue'); @@ -489,7 +773,7 @@ describe('gh-aw-enlistment skill', () => { const readmeInstall = readme.slice(readme.indexOf('### Install')); expect(readmeInstall.indexOf("issues_enabled=")).toBeGreaterThan(-1); expect(readmeInstall.indexOf("issues_enabled=")).toBeLessThan( - readmeInstall.indexOf('gh aw add "bradygaster/squad/workflows@${SQUAD_SHA}"'), + readmeInstall.indexOf(INSTALL_COMMAND), ); expect(readmeInstall).toContain( 'test "$(gh api "repos/${owner_repo}" --jq \'.has_issues\')" = "true" || {', @@ -527,21 +811,36 @@ describe('gh-aw-enlistment skill', () => { }); it('makes public-guide package verification fail fast and coherent', () => { - expect(guide).toContain('gh aw add "bradygaster/squad/workflows@${SQUAD_SHA}"'); + expect(guide).toContain(INSTALL_COMMAND); expect(guide).toContain('squad-install-verifier.mjs --materialize-runtime'); expect(guide).toContain('--verify-install'); expect(guide).toContain('--source-revision "${SQUAD_SHA}"'); expect(guide).toContain('--strict-compile'); expect(guide).toContain('package ownership metadata'); const upgrade = guide.slice(guide.indexOf('## Upgrading')); - const add = upgrade.indexOf('gh aw add "bradygaster/squad/workflows@${SQUAD_SHA}" --force'); + const add = upgrade.indexOf(`${INSTALL_COMMAND} --force`); const cleanup = upgrade.indexOf('rm -f .github/skills/agentic-workflows/SKILL.md'); + const legacyCleanup = upgrade.indexOf(`rm -f ${LEGACY_OWNERSHIP}\n`); const compile = upgrade.indexOf('gh aw compile --strict'); const verify = upgrade.indexOf('squad-install-verifier.mjs \\\n --verify-install'); expect(add).toBeGreaterThan(-1); expect(cleanup).toBeGreaterThan(add); + expect(legacyCleanup, `Upgrading must delete the superseded ${LEGACY_OWNERSHIP}`).toBeGreaterThan(add); expect(compile).toBeGreaterThan(cleanup); + expect(compile).toBeGreaterThan(legacyCleanup); expect(verify).toBeGreaterThan(compile); + const flatUpgrade = upgrade.replace(/\s+/g, ' '); + expect(flatUpgrade).toContain('the verifier fails closed while both records exist'); + expect(flatUpgrade).toContain( + `action \`${SQUAD_INIT_ACTION}\` added and \`bradygaster/squad/.github/actions/squad-init\` removed.`, + ); + const gate = extractIdentityGate(readLF(CANONICAL)); + expect(upgrade.indexOf(gate), 'Upgrading must run the identity gate').toBeGreaterThan(-1); + expect(upgrade.indexOf(gate), 'Upgrading must check the identity before reinstalling').toBeLessThan(add); + expect(flatUpgrade, 'Upgrading must say how to stay on a legacy-only revision').toContain( + `with \`${LEGACY_INSTALL_PREFIX}\${SQUAD_SHA}" --force\`, and skip the second \`rm -f\`: ` + + 'that ownership record is the only one such an installation has.', + ); }); }); diff --git a/test/gh-aw-hosted-e2e.test.ts b/test/gh-aw-hosted-e2e.test.ts index 1de628462..2bd9becfa 100644 --- a/test/gh-aw-hosted-e2e.test.ts +++ b/test/gh-aw-hosted-e2e.test.ts @@ -2,15 +2,17 @@ import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { parse } from 'yaml'; import { + TRUSTED_SOURCE, assertPristineTarget, guardedTargetMutation, sanitizedEnvironment, selectBootstrapFallback, selectBootstrapOutputs, selectManualFallbackCastPr, + sourcePreflight, waitForBaseControlledReviewCanary, waitForBootstrapCompletion, waitForBootstrapFallback, @@ -36,12 +38,46 @@ const packageArtifactCases = [ ...CONTRACT.runtime.map(({ packageDestination }) => ['runtime package destination', packageDestination]), ...CONTRACT.runtime.map(({ destination }) => ['materialized runtime destination', destination]), ['installed manifest', '.github/aw/squad-workflows.manifest.json'], - ['package ownership metadata', '.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json'], + ['package ownership metadata', '.github/aw/packages/github-squad-workflows-23a7638f599e.json'], + ['legacy package ownership metadata', '.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json'], ...CONTRACT.skills.map(({ destination }) => ['installed skill', destination]), ['trigger sentinel', '.github/workflows/shared/squad-bootstrap-trigger-probe.json'], ['package namespace residue', '.github/aw/squad/unknown-stale-file.md'], ] as const; +// Live identity of the trusted source after the move (GET repos/github/squad, 2026-11-07). +const LIVE_SOURCE = { + repository: 'github/squad', + repositoryId: 1151205052, + owner: 'github', + ownerId: 9919, +} as const; + +type GuardContext = Record; + +const CANONICAL_DISPATCH: GuardContext = { + 'github.repository': LIVE_SOURCE.repository, + 'github.repository_id': String(LIVE_SOURCE.repositoryId), + 'github.repository_owner': LIVE_SOURCE.owner, + 'github.ref_name': 'dev', + 'github.event.repository.default_branch': 'dev', +}; + +// Evaluates the `==` / `&&` subset used by the job guard; any other syntax or an unmodeled +// context path fails the test instead of being guessed. +function evaluateJobGuard(expression: string, context: GuardContext): boolean { + return expression.split('&&').map(clause => clause.trim()).every((clause) => { + const match = /^([a-z_.]+) == (?:'([^']*)'|([a-z_.]+))$/.exec(clause); + if (!match) throw new Error(`Unsupported hosted E2E guard clause: ${clause}`); + const [, left, literal, right] = match; + const read = (path: string): string => { + if (!(path in context)) throw new Error(`Hosted E2E guard reads an unmodeled context path: ${path}`); + return context[path]; + }; + return read(left) === (literal ?? read(right)); + }); +} + function fakeGitHub(overrides: Record = {}) { return { getDefaultBranchSha: () => TARGET_SHA, @@ -225,6 +261,32 @@ describe('Squad gh-aw hosted E2E controller', () => { expect(parsed.permissions).toEqual({ contents: 'read' }); }); + it.each([ + ['the canonical github/squad default branch', {}, true], + ['the same repository after a rename or transfer (source-preflight then fails loudly)', { + 'github.repository': 'github/squad-renamed', + }, true], + ['a fork with another repository ID', { + 'github.repository': 'someone/squad', + 'github.repository_id': '42', + 'github.repository_owner': 'someone', + }, false], + ['a non-default branch', { 'github.ref_name': 'feature' }, false], + ] as const)('evaluates the hosted job guard for %s', (label, overrides, expected) => { + const guard = parse(WORKFLOW).jobs['hosted-e2e'].if as string; + expect( + evaluateJobGuard(guard, { ...CANONICAL_DISPATCH, ...overrides }), + `hosted-e2e if: "${guard}" must be ${expected} for ${label}`, + ).toBe(expected); + }); + + it('binds the hosted job guard to the immutable trusted repository ID, not a mutable name', () => { + const guard = parse(WORKFLOW).jobs['hosted-e2e'].if as string; + expect(guard, `hosted-e2e if: "${guard}" must not gate on github.repository`) + .not.toMatch(/github\.repository ==/); + expect(guard).toContain(`github.repository_id == '${TRUSTED_SOURCE.repositoryId}'`); + }); + it('removes credentials from candidate child process environments', () => { process.env.GH_TOKEN = 'secret'; process.env.SQUAD_GH_AW_E2E_TOKEN = 'secret'; @@ -245,18 +307,51 @@ describe('Squad gh-aw hosted E2E controller', () => { }); it('binds source and target identities without caller-selected privileged code', () => { - expect(SCRIPT).toContain("repository: 'bradygaster/squad'"); - expect(SCRIPT).toContain('repositoryId: 1151205052'); + expect(TRUSTED_SOURCE, 'trusted source must match the live github/squad identity').toEqual(LIVE_SOURCE); + expect(Object.isFrozen(TRUSTED_SOURCE)).toBe(true); expect(SCRIPT).toContain('sourceRef !== `refs/heads/${info.default_branch}`'); expect(SCRIPT).toContain('branch.protected !== true || branch.sha !== sourceSha'); expect(SCRIPT).toContain('info.full_name !== target'); expect(SCRIPT).toContain('info.id !== expectedRepositoryId || info.owner.id !== expectedOwnerId'); expect(SCRIPT).toContain('actor.login !== expectedActorLogin || actor.id !== expectedActorId'); + expect(SCRIPT, 'hosted controller must require a github/squad/workflows-owned install') + .toContain('if (result.packageName !== PACKAGE_NAME)'); + }); + + describe('source-preflight repository identity gate', () => { + const args = (repository: string, repositoryId: string) => ({ + source_repository: repository, + source_repository_id: repositoryId, + source_ref: 'refs/heads/dev', + source_sha: 'a'.repeat(40), + }); + + // No credential may reach the API read; a gate regression must fail here, not call GitHub. + beforeEach(() => { + vi.stubEnv('SOURCE_READ_TOKEN', undefined); + vi.stubEnv('GH_TOKEN', undefined); + }); + afterEach(() => vi.unstubAllEnvs()); + + it.each([ + ['pre-transfer repository name', 'bradygaster/squad', String(LIVE_SOURCE.repositoryId)], + ['foreign repository ID', LIVE_SOURCE.repository, '42'], + ])('rejects the %s (%s, %s) before any API read', (_label, repository, repositoryId) => { + expect(() => sourcePreflight(args(repository, repositoryId), ROOT)) + .toThrow('Hosted E2E controller must run from the trusted Squad repository.'); + }); + + it('admits github/squad (1151205052) past the identity gate to the token-gated API read', () => { + expect(() => sourcePreflight( + args(LIVE_SOURCE.repository, String(LIVE_SOURCE.repositoryId)), + ROOT, + )).toThrow('A source repository read token is required.'); + }); }); it('loads only the exact canonical manifest and has no fallback contract', () => { const contract = loadBundleContract(ROOT); - expect(contract.package).toBe('bradygaster/squad/workflows'); + expect(contract.package).toBe('github/squad/workflows'); expect(contract.workflows).toHaveLength(8); expect(contract.runtime).toHaveLength(21); expect(contract.skills).toHaveLength(1); @@ -423,7 +518,7 @@ describe('Squad gh-aw hosted E2E controller', () => { 'main', CONTRACT, fakeGitHub({ getTree: () => ({ truncated: false, paths: [artifactPath] }) }), - )).toThrow(/Target is not pristine/); + ), `pristine target check must reject ${artifactPath}`).toThrow(/Target is not pristine/); }); it.each([ diff --git a/test/gh-aw-implement-workflow.test.ts b/test/gh-aw-implement-workflow.test.ts index 980b079db..d8bb77711 100644 --- a/test/gh-aw-implement-workflow.test.ts +++ b/test/gh-aw-implement-workflow.test.ts @@ -262,10 +262,11 @@ describe('gh-aw implement workflows', () => { it('documents one immutable nested native package installation', () => { const normalizedGuide = guide.replace(/\r\n/g, '\n'); - expect(normalizedGuide).toContain('gh aw add "bradygaster/squad/workflows@${SQUAD_SHA}"'); + expect(normalizedGuide, 'docs guide must install github/squad/workflows') + .toContain('gh aw add "github/squad/workflows@${SQUAD_SHA}"'); expect(normalizedGuide).toContain('exactly eight agentic workflows, twenty-one runtime'); expect(normalizedGuide).toContain('one enlistment skill'); - expect(normalizedGuide).not.toMatch(/gh aw add \\\n\s+bradygaster\/squad\/workflows\/squad\.md/); + expect(normalizedGuide).not.toMatch(/gh aw add \\\n\s+(?:github|bradygaster)\/squad\/workflows\/squad\.md/); }); }); diff --git a/test/gh-aw-install-staging.test.ts b/test/gh-aw-install-staging.test.ts index c22e6c461..893bf3877 100644 --- a/test/gh-aw-install-staging.test.ts +++ b/test/gh-aw-install-staging.test.ts @@ -7,11 +7,15 @@ import { pathToFileURL } from 'node:url'; import { parse } from 'yaml'; import { CONTRACT_DESTINATION, + LEGACY_OWNERSHIP_DESTINATIONS, + LEGACY_PACKAGE_NAMES, OWNERSHIP_DESTINATION, + PACKAGE_NAME, UNOWNED_MUTABLE_ROUTER_SKILL, WORKFLOW_NAMES, materializeRuntime, compileWithPinnedActions, + packageOwnershipDestination, verifyInstall, verifyStagedInstall, } from '../workflows/shared/squad-install-verifier.mjs'; @@ -23,6 +27,8 @@ vi.mock('node:child_process', async (importOriginal) => { }); const revision = 'a'.repeat(40); +const [LEGACY_PACKAGE_NAME] = LEGACY_PACKAGE_NAMES; +const [LEGACY_OWNERSHIP] = LEGACY_OWNERSHIP_DESTINATIONS; const roots: string[] = []; const verifier = resolve('workflows/shared/squad-install-verifier.mjs'); @@ -35,14 +41,14 @@ function write(root: string, path: string, content: string | Buffer): void { writeFileSync(join(root, path), content); } -function consumer(): string { +function consumer(packageName: string = PACKAGE_NAME): string { const root = mkdtempSync(join(tmpdir(), 'squad-install-staging-')); roots.push(root); git(root, 'init', '--quiet'); git(root, 'config', 'core.autocrlf', 'false'); - const fixture = createFirstInstallFixture(revision, 'package'); + const fixture = createFirstInstallFixture(revision, 'package', packageName); for (const [path, content] of fixture.consumerFiles) write(root, path, content); - write(root, OWNERSHIP_DESTINATION, `${JSON.stringify(fixture.provenance, null, 2)}\n`); + write(root, packageOwnershipDestination(packageName), `${JSON.stringify(fixture.provenance, null, 2)}\n`); write(root, '.gitattributes', '* -text\n'); write(root, '.gitignore', 'packages/\nlogs/\n*.local\n'); write(root, '.github/aw/packages/unrelated.json', '{"private":true}\n'); @@ -141,10 +147,11 @@ describe('gh-aw: verified ownership staging under consumer ignore rules', () => expect(verifyStagedInstall(root, { expectedRevision: revision, stageOwnership: true }).failures).toEqual([]); const queries = spawn.mock.calls.filter(([command, args]) => command === 'git' && args?.[0] === 'ls-tree'); expect(queries).toHaveLength(1); - expect(queries[0][1]).toEqual([ - 'ls-tree', '-z', expect.any(String), '--', ...new Set(required), - '.github/workflows/aw.json', UNOWNED_MUTABLE_ROUTER_SKILL, - ]); + expect(queries[0][1], `ls-tree must stay bounded to exact paths, including superseded ${LEGACY_OWNERSHIP}`) + .toEqual([ + 'ls-tree', '-z', expect.any(String), '--', ...new Set(required), + '.github/workflows/aw.json', UNOWNED_MUTABLE_ROUTER_SKILL, ...LEGACY_OWNERSHIP_DESTINATIONS, + ]); const unrelatedObject = git(root, 'rev-parse', `HEAD:${unrelated}/ordinary-file-00000.txt`).trim(); const blobReads = spawn.mock.calls.filter(([command, args]) => command === 'git' && args?.[0] === 'cat-file'); expect(blobReads).toHaveLength(new Set(required).size); @@ -225,9 +232,9 @@ describe('gh-aw: verified ownership staging under consumer ignore rules', () => expect(rejected.stderr).toContain( `Unowned mutable gh-aw router skill must be removed: ${UNOWNED_MUTABLE_ROUTER_SKILL}`, ); - expect(rejected.stderr).toContain([ - 'Safe recovery:', - ` gh aw add bradygaster/squad/workflows@${revision} --force`, + expect(rejected.stderr, `recovery must also delete ${LEGACY_OWNERSHIP}`).toContain([ + ` gh aw add github/squad/workflows@${revision} --force`, + ' rm -f .github/aw/packages/bradygaster-squad-workflows-3632054824e8.json', ` rm -f ${UNOWNED_MUTABLE_ROUTER_SKILL}`, ' gh aw compile --strict', ' node .github/workflows/shared/squad-install-verifier.mjs --verify-install --strict-compile', @@ -270,6 +277,81 @@ describe('gh-aw: verified ownership staging under consumer ignore rules', () => ); }); + it('force-stages a legacy-identity install at its own native ownership path', () => { + const root = consumer(LEGACY_PACKAGE_NAME); + expect(stagedPaths(root)).not.toContain(LEGACY_OWNERSHIP); + const result = verifyStagedInstall(root, { expectedRevision: revision, stageOwnership: true }); + expect(result.failures, `legacy record ${LEGACY_OWNERSHIP} must stage and verify`).toEqual([]); + expect(result.packageName).toBe(LEGACY_PACKAGE_NAME); + expect(stagedPaths(root)).toContain(LEGACY_OWNERSHIP); + expect(stagedPaths(root)).not.toContain(OWNERSHIP_DESTINATION); + }, 120_000); + + it('rejects a superseded legacy ownership record left in the staged tree after migrating', () => { + const root = consumer(); + // The legacy install committed its record; reinstalling as github/squad/workflows leaves it. + write(root, LEGACY_OWNERSHIP, readFileSync(join(root, OWNERSHIP_DESTINATION), 'utf8') + .replaceAll(PACKAGE_NAME, LEGACY_PACKAGE_NAME)); + git(root, 'add', '--force', '--', LEGACY_OWNERSHIP, OWNERSHIP_DESTINATION); + git(root, '-c', 'user.name=Test', '-c', 'user.email=test@example.invalid', + '-c', 'core.hooksPath=/dev/null', 'commit', '--quiet', '-m', 'legacy and migrated records'); + expect( + verifyInstall(root).failures.join('\n'), + `${OWNERSHIP_DESTINATION} and ${LEGACY_OWNERSHIP} both present`, + ).toContain('Expected exactly one Squad package ownership record; found 2'); + + rmSync(join(root, LEGACY_OWNERSHIP)); + expect(verifyInstall(root).failures).toEqual([]); + expect( + verifyStagedInstall(root, { stageOwnership: true }).failures.join('\n'), + `index still holds deleted ${LEGACY_OWNERSHIP}`, + ).toContain( + `Superseded Squad package ownership record remains in staged tree: ${LEGACY_OWNERSHIP}`, + ); + + git(root, 'add', '--', '.github/aw/'); + expect(verifyStagedInstall(root, { stageOwnership: true }).failures).toEqual([]); + expect(git(root, 'diff', '--cached', '--diff-filter=D', '--name-only').trim()).toBe(LEGACY_OWNERSHIP); + }, 120_000); + + it('moves a legacy-owned install with a staged canonical record forward instead of deleting it', () => { + const root = consumer(LEGACY_PACKAGE_NAME); + // The canonical record was committed earlier, then removed from the working tree. + write(root, OWNERSHIP_DESTINATION, readFileSync(join(root, LEGACY_OWNERSHIP), 'utf8') + .replaceAll(LEGACY_PACKAGE_NAME, PACKAGE_NAME)); + git(root, 'add', '--force', '--', LEGACY_OWNERSHIP, OWNERSHIP_DESTINATION); + git(root, '-c', 'user.name=Test', '-c', 'user.email=test@example.invalid', + '-c', 'core.hooksPath=/dev/null', 'commit', '--quiet', '-m', 'canonical and legacy records'); + rmSync(join(root, OWNERSHIP_DESTINATION)); + expect(verifyInstall(root).failures).toEqual([]); + const failure = verifyStagedInstall(root, { stageOwnership: true }).failures.join('\n'); + const state = `${OWNERSHIP_DESTINATION} still staged in a ${LEGACY_PACKAGE_NAME} install`; + expect(failure, state).toContain( + `Squad package ownership record ${OWNERSHIP_DESTINATION} (${PACKAGE_NAME}) remains in the staged tree ` + + `of this ${LEGACY_PACKAGE_NAME} installation; do not delete it.`, + ); + expect(failure, state).toContain( + `Reinstall with gh aw add ${PACKAGE_NAME}@${revision} --force, delete ${LEGACY_OWNERSHIP}, and stage both changes`, + ); + expect(failure, state).not.toContain('delete it and stage the deletion'); + }, 120_000); + + it('prints a non-fatal legacy-identity notice only after a legacy install verifies', () => { + const args = [verifier, '--verify-install', '--source-revision', revision]; + const legacy = spawnSync(process.execPath, args, { cwd: consumer(LEGACY_PACKAGE_NAME), encoding: 'utf8' }); + expect(legacy.status, legacy.stderr).toBe(0); + expect(legacy.stdout).toContain(`Squad gh-aw installation verified at ${revision}.`); + expect(legacy.stderr, `a ${LEGACY_PACKAGE_NAME} install must print the move notice`).toContain([ + `Notice: verified under the legacy ${LEGACY_PACKAGE_NAME} package identity, which remains supported.`, + `To move to ${PACKAGE_NAME}, follow the gh-aw guide's Upgrading steps, starting with:`, + ` gh aw add ${PACKAGE_NAME}@${revision} --force`, + ` rm -f ${LEGACY_OWNERSHIP}`, + ].join('\n')); + const canonical = spawnSync(process.execPath, args, { cwd: consumer(), encoding: 'utf8' }); + expect(canonical.status, canonical.stderr).toBe(0); + expect(canonical.stderr, `a ${PACKAGE_NAME} install must not print the legacy notice`).not.toContain('Notice:'); + }, 120_000); + it('fails clearly when Git cannot force-stage the required ignored metadata', () => { const root = consumer(); write(root, '.git/index.lock', 'locked\n'); diff --git a/test/gh-aw-quality.test.ts b/test/gh-aw-quality.test.ts index ed634f12c..95d1bcb14 100644 --- a/test/gh-aw-quality.test.ts +++ b/test/gh-aw-quality.test.ts @@ -33,7 +33,10 @@ import { import { CONTRACT_DESTINATION, CONTRACT_SOURCE, + LEGACY_OWNERSHIP_DESTINATIONS, + LEGACY_PACKAGE_NAMES, MIN_GH_AW_VERSION, + OWNERSHIP_DESTINATION, OWNERSHIP_ENTRY_COUNT, PACKAGE_NAME, RUNTIME_TUPLES, @@ -46,10 +49,12 @@ import { compileWithPinnedActions, materializeRuntime, normalizeCompiledLock, + packageOwnershipDestination, validateContract, validateCompilerActionPins, verifyInstall, verifyCanonicalManifest, + verifyResource, writeLocalTestOwnership, } from '../workflows/shared/squad-install-verifier.mjs'; @@ -1936,7 +1941,7 @@ describe('gh-aw: compiled workflow shell input security contract', () => { /- name: Install Squad CLI from standalone release[\s\S]*?(?=\n\s+- name:)/, )?.[0]; expect(install, 'compiled squad.lock.yml must retain the standalone installer').toContain( - 'uses: bradygaster/squad/.github/actions/squad-init@5c662ba015ec4f99befa51b0e8ca4f2148b0497f', + 'uses: github/squad/.github/actions/squad-init@5c662ba015ec4f99befa51b0e8ca4f2148b0497f', ); expect(install, 'compiled squad.lock.yml must omit version to use the pinned action latest default') .not.toMatch(/^\s+version:/m); @@ -3609,8 +3614,8 @@ describe('gh-aw: shared bootstrap health-before-dispatch contract (#1605)', () = const healthLine = lines[healthLineIdx]; expect(healthLine, 'health must invoke the installed squad binary').toMatch(/\bsquad health --json/); - expect(sharedContent).toContain( - 'uses: bradygaster/squad/.github/actions/squad-init@5c662ba015ec4f99befa51b0e8ca4f2148b0497f', + expect(sharedContent, 'shared/squad.md must pin squad-init through the canonical github/squad owner').toContain( + 'uses: github/squad/.github/actions/squad-init@5c662ba015ec4f99befa51b0e8ca4f2148b0497f', ); expect(sharedContent).not.toContain('squad-release'); expect(sharedContent).toContain('SQUAD_CLI_VERSION: ${{ steps.squad-cli.outputs.version }}'); @@ -4013,6 +4018,9 @@ describe('gh-aw: CI compiler pin contract', () => { describe('gh-aw: canonical package integrity contract', () => { const revisionA = 'a'.repeat(40); const revisionB = 'b'.repeat(40); + const [LEGACY_PACKAGE_NAME] = LEGACY_PACKAGE_NAMES; + // The verifier finds records by their `package` field, so the fixture filename is neutral. + const OWNERSHIP_FIXTURE = '.github/aw/packages/squad-workflows-test.json'; function copyInto(root: string, source: string, destination: string): void { const target = join(root, destination); @@ -4024,9 +4032,10 @@ describe('gh-aw: canonical package integrity contract', () => { revision = revisionA, materialize = true, sourceBinding: 'workflow' | 'package' | 'mixed' = 'workflow', + packageName: string = PACKAGE_NAME, ): string { const root = createTestWorkspace('gh-aw-package-contract-'); - const install = createFirstInstallFixture(revision, sourceBinding); + const install = createFirstInstallFixture(revision, sourceBinding, packageName); copyInto(root, CONTRACT_SOURCE, CONTRACT_DESTINATION); const contract = JSON.parse(readFileSync(join(process.cwd(), CONTRACT_SOURCE), 'utf8')); for (const workflow of contract.workflows) { @@ -4058,12 +4067,12 @@ describe('gh-aw: canonical package integrity contract', () => { destination: entry.destination, sha256: createHash('sha256').update(readFileSync(join(root, entry.destination))).digest('hex'), })).sort((left, right) => left.destination.localeCompare(right.destination)); - const ownership = join(root, '.github/aw/packages/bradygaster-squad-workflows-test.json'); + const ownership = join(root, OWNERSHIP_FIXTURE); mkdirSync(dirname(ownership), { recursive: true }); writeFileSync(ownership, `${JSON.stringify({ schemaVersion: 1, - package: PACKAGE_NAME, - source: `${PACKAGE_NAME}@${revision}`, + package: packageName, + source: `${packageName}@${revision}`, resolvedCommit: revision, installer: 'gh-aw v0.91.5 test', files: owned, @@ -4124,7 +4133,7 @@ describe('gh-aw: canonical package integrity contract', () => { })); expect(contract.skills).toHaveLength(SKILL_TUPLES.length); expect(contract.skills).toHaveLength(1); - expect(contract.package).toBe('bradygaster/squad/workflows'); + expect(contract.package).toBe('github/squad/workflows'); expect(contract.manifest).toBe('workflows/aw.yml'); expect(contract.bootstrap.trigger_probe).toBe(TRIGGER_PROBE); expect(contract.shared_runtime.some((entry: { path: string }) => entry.path === TRIGGER_PROBE)).toBe(false); @@ -4191,10 +4200,7 @@ describe('gh-aw: canonical package integrity contract', () => { it('accepts a coherent consumer with the exact ownership cardinality', () => { const root = makeConsumer(); expect(verifyInstall(root, { expectedRevision: revisionA }).failures).toEqual([]); - const record = JSON.parse(readFileSync( - join(root, '.github/aw/packages/bradygaster-squad-workflows-test.json'), - 'utf8', - )); + const record = JSON.parse(readFileSync(join(root, OWNERSHIP_FIXTURE), 'utf8')); expect(record.files).toHaveLength(OWNERSHIP_ENTRY_COUNT); }); @@ -4315,7 +4321,7 @@ describe('gh-aw: canonical package integrity contract', () => { it.each(['v0.89.22', 'v0.91.4', 'v0.91.50', 'v0.91.5-preview'])( 'rejects ownership from unsupported installer %s', version => { const root = makeConsumer(); - const path = join(root, '.github/aw/packages/bradygaster-squad-workflows-test.json'); + const path = join(root, OWNERSHIP_FIXTURE); const record = JSON.parse(readText(path)); record.installer = `gh-aw ${version}`; writeFileSync(path, JSON.stringify(record)); @@ -4476,7 +4482,7 @@ describe('gh-aw: canonical package integrity contract', () => { }, 120_000); function updateOwnedDigest(root: string, destination: string): void { - const path = join(root, '.github/aw/packages/bradygaster-squad-workflows-test.json'); + const path = join(root, OWNERSHIP_FIXTURE); const record = JSON.parse(readFileSync(path, 'utf8')); const entry = record.files.find((file: { destination: string }) => file.destination === destination); entry.sha256 = createHash('sha256').update(readFileSync(join(root, destination))).digest('hex'); @@ -4488,8 +4494,10 @@ describe('gh-aw: canonical package integrity contract', () => { const sourcePath = '.github/workflows/squad-review.md'; const lockPath = '.github/workflows/squad-review.lock.yml'; const mutations: Array<[string, (text: string) => string]> = [ - ['wrong owner', text => text.replaceAll('bradygaster/squad', 'attacker/squad')], - ['wrong repo', text => text.replaceAll('bradygaster/squad', 'bradygaster/other')], + ['wrong owner', text => text.replaceAll('github/squad', 'attacker/squad')], + ['wrong repo', text => text.replaceAll('github/squad', 'github/other')], + // An ownership record binds exactly one identity; a legacy binding must not mix in. + ['legacy owner', text => text.replaceAll(PACKAGE_NAME, LEGACY_PACKAGE_NAME)], ['wrong package', text => text.replaceAll('/workflows', '/other')], ['wrong workflow', text => text.replaceAll(source, `${PACKAGE_NAME}/package/squad.md@${revisionA}`)], ['wrong revision', text => text.replaceAll(revisionA, revisionB)], @@ -4508,8 +4516,9 @@ describe('gh-aw: canonical package integrity contract', () => { } const lockMutations: Array<[string, (text: string) => string]> = [ - ...mutations.slice(0, 6), - ['wrong URL', text => text.replace('/bradygaster/squad/blob/', '/attacker/squad/blob/')], + ...mutations.slice(0, 7), + ['wrong URL', text => text.replace('/github/squad/blob/', '/attacker/squad/blob/')], + ['legacy URL', text => text.replace('/github/squad/blob/', '/bradygaster/squad/blob/')], ['mixed annotation', text => text.replace(`GH_AW_WORKFLOW_SOURCE: "${source}"`, 'GH_AW_WORKFLOW_SOURCE: "unknown"')], ['missing annotation', text => text.replace(` GH_AW_INFO_FRONTMATTER_SOURCE: "${source}"\n`, '')], ['duplicate annotation', text => text.replace(`GH_AW_WORKFLOW_SOURCE: "${source}"`, `GH_AW_WORKFLOW_SOURCE: "${source}"\n GH_AW_WORKFLOW_SOURCE: "${source}"`)], @@ -4674,14 +4683,14 @@ describe('gh-aw: canonical package integrity contract', () => { it('rejects ownership mismatches and mixed revisions', () => { const root = makeConsumer(); - const ownershipPath = join(root, '.github/aw/packages/bradygaster-squad-workflows-test.json'); + const ownershipPath = join(root, OWNERSHIP_FIXTURE); const ownership = JSON.parse(readFileSync(ownershipPath, 'utf8')); ownership.source = `${PACKAGE_NAME}@${revisionB}`; writeFileSync(ownershipPath, `${JSON.stringify(ownership, null, 2)}\n`); expect(verifyInstall(root).failures.join('\n')).toContain('Package ownership source'); const extra = makeConsumer(); - const extraPath = join(extra, '.github/aw/packages/bradygaster-squad-workflows-test.json'); + const extraPath = join(extra, OWNERSHIP_FIXTURE); const extraOwnership = JSON.parse(readFileSync(extraPath, 'utf8')); extraOwnership.files.push(extraOwnership.files[0]); writeFileSync(extraPath, `${JSON.stringify(extraOwnership, null, 2)}\n`); @@ -4690,6 +4699,164 @@ describe('gh-aw: canonical package integrity contract', () => { ); }); + it('derives the gh-aw v0.91.5 native ownership record path for each accepted identity', () => { + // Both literals were observed from real `gh aw add @` installs. + expect(OWNERSHIP_DESTINATION, `gh-aw native record path for ${PACKAGE_NAME}`) + .toBe('.github/aw/packages/github-squad-workflows-23a7638f599e.json'); + expect(LEGACY_OWNERSHIP_DESTINATIONS, `gh-aw native record path for ${LEGACY_PACKAGE_NAME}`).toEqual([ + '.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json', + ]); + expect(packageOwnershipDestination(PACKAGE_NAME)).toBe(OWNERSHIP_DESTINATION); + expect(() => packageOwnershipDestination('attacker/squad/workflows')) + .toThrow('Unsupported Squad package identity: "attacker/squad/workflows".'); + }); + + it('translates only gh-aw source metadata lines when hashing a legacy-identity lock', () => { + const lock = (repository: string, body: string) => [ + `# gh-aw-metadata: {"frontmatter_hash":"${'a'.repeat(64)}"}`, + `# To update this file, edit ${repository}/workflows@${revisionA} and run:`, + `# Source: ${repository}/workflows/package/squad-retro.md@${revisionA}`, + ` GH_AW_WORKFLOW_SOURCE: "${repository}/workflows@${revisionA}"`, + ` GH_AW_INFO_FRONTMATTER_SOURCE: "${repository}/workflows/package/squad-retro.md@${revisionA}"`, + ` GH_AW_WORKFLOW_SOURCE_URL: "\${{ github.server_url }}/${repository}/blob/${revisionA}/workflows"`, + body, + ].join('\n'); + // Same bytes in both installs, so they must survive translation unchanged. + const body = [ + ` uses: bradygaster/squad/.github/actions/squad-init@${revisionA}`, + ` # gh aw add bradygaster/squad/workflows@${revisionA}`, + ].join('\n'); + const canonical = normalizeCompiledLock(lock('github/squad', body), revisionA); + expect( + normalizeCompiledLock(lock('bradygaster/squad', body), revisionA, LEGACY_PACKAGE_NAME), + 'a legacy lock must hash exactly like its canonical twin', + ).toBe(canonical); + expect(normalizeCompiledLock(lock('bradygaster/squad', body), revisionA)).not.toBe(canonical); + expect(() => normalizeCompiledLock(lock('github/squad', body), revisionA, LEGACY_PACKAGE_NAME), + `a ${LEGACY_PACKAGE_NAME} lock carrying github/squad source metadata must be rejected`).toThrow( + `Compiled lock mixes ${PACKAGE_NAME} source metadata into a ${LEGACY_PACKAGE_NAME} installation.`, + ); + expect(() => normalizeCompiledLock(lock('attacker/squad', body), revisionA, 'attacker/squad/workflows')) + .toThrow('Unsupported Squad package identity'); + }); + + it.each(['workflow', 'package', 'mixed'] as const)( + 'verifies a coherent legacy bradygaster/squad/workflows %s install against canonical digests', + sourceBinding => { + const root = makeConsumer(revisionA, true, sourceBinding, LEGACY_PACKAGE_NAME); + const result = verifyInstall(root, { expectedRevision: revisionA }); + expect(result.failures, `legacy ${sourceBinding} install must verify`).toEqual([]); + expect(result.packageName).toBe(LEGACY_PACKAGE_NAME); + expect(verifyResource(root, '.github/workflows/squad-review.md')).toMatch(/^[0-9a-f]{64}$/); + expect(verifyInstall(makeConsumer(revisionA, true, sourceBinding)).packageName).toBe(PACKAGE_NAME); + }, + 120_000, + ); + + it('strict-compiles a legacy-identity consumer without relaxing lock comparison', () => { + const root = makeConsumer(revisionA, true, 'package', LEGACY_PACKAGE_NAME); + execFileSync('git', ['init', '--quiet'], { cwd: root }); + execFileSync('git', ['remote', 'add', 'origin', 'https://github.com/example/squad-consumer.git'], { cwd: root }); + compileWithPinnedActions(root); + expect(verifyInstall(root, { expectedRevision: revisionA, strictCompile: true }).failures).toEqual([]); + const lockPath = join(root, '.github/workflows/squad-review.lock.yml'); + writeFileSync(lockPath, readText(lockPath).replace('contents: read', 'contents: write')); + expect(verifyInstall(root).failures.join('\n')) + .toContain('Installed digest mismatch for .github/workflows/squad-review.lock.yml'); + }, 120_000); + + it('rejects a legacy install whose compiled lock carries canonical source metadata', () => { + const root = makeConsumer(revisionA, true, 'package', LEGACY_PACKAGE_NAME); + const lockPath = join(root, '.github/workflows/squad-review.lock.yml'); + const legacySource = `GH_AW_WORKFLOW_SOURCE: "${LEGACY_PACKAGE_NAME}@${revisionA}"`; + const original = readText(lockPath); + expect(original).toContain(legacySource); + writeFileSync(lockPath, original.replace(legacySource, `GH_AW_WORKFLOW_SOURCE: "${PACKAGE_NAME}@${revisionA}"`)); + expect( + verifyInstall(root).failures.join('\n'), + `.github/workflows/squad-review.lock.yml mixes ${PACKAGE_NAME} metadata into a ${LEGACY_PACKAGE_NAME} install`, + ).toContain( + `Compiled lock mixes ${PACKAGE_NAME} source metadata into a ${LEGACY_PACKAGE_NAME} installation.`, + ); + }); + + it('rejects a canonical source binding inside a legacy-owned install', () => { + const root = makeConsumer(revisionA, true, 'package', LEGACY_PACKAGE_NAME); + const sourcePath = '.github/workflows/squad-review.md'; + const original = readText(join(root, sourcePath)); + const modified = original.replace(`source: ${LEGACY_PACKAGE_NAME}@`, `source: ${PACKAGE_NAME}@`); + expect(modified).not.toBe(original); + writeFileSync(join(root, sourcePath), modified); + updateOwnedDigest(root, sourcePath); + expect( + verifyInstall(root).failures.join('\n'), + `${sourcePath} binds ${PACKAGE_NAME} under a ${LEGACY_PACKAGE_NAME} ownership record`, + ).toContain(`Installed source binding is invalid for ${sourcePath}.`); + }); + + it('fails closed with migration guidance while both identity records exist', () => { + // `gh aw add github/squad/workflows@ --force` over a legacy install leaves this state. + const root = makeConsumer(); + const record = JSON.parse(readText(join(root, OWNERSHIP_FIXTURE))); + const [legacyPath] = LEGACY_OWNERSHIP_DESTINATIONS; + mkdirSync(dirname(join(root, legacyPath)), { recursive: true }); + writeFileSync(join(root, legacyPath), `${JSON.stringify({ + ...record, + package: LEGACY_PACKAGE_NAME, + source: `${LEGACY_PACKAGE_NAME}@${revisionA}`, + }, null, 2)}\n`); + const failure = verifyInstall(root).failures.join('\n'); + const state = `${OWNERSHIP_FIXTURE} (${PACKAGE_NAME}) and ${legacyPath} (${LEGACY_PACKAGE_NAME}) both present`; + expect(failure, state).toContain('Expected exactly one Squad package ownership record; found 2'); + expect(failure, state).toContain(`${legacyPath} (${LEGACY_PACKAGE_NAME})`); + expect(failure, state).toContain(`after reinstalling as ${PACKAGE_NAME}, delete ${legacyPath}.`); + unlinkSync(join(root, legacyPath)); + expect(verifyInstall(root, { expectedRevision: revisionA }).failures).toEqual([]); + }); + + it('rejects unknown identities and cross-identity ownership sources', () => { + const unknown = makeConsumer(); + const unknownPath = join(unknown, OWNERSHIP_FIXTURE); + const record = JSON.parse(readText(unknownPath)); + writeFileSync(unknownPath, JSON.stringify({ + ...record, package: 'attacker/squad/workflows', source: `attacker/squad/workflows@${revisionA}`, + })); + expect(verifyInstall(unknown).failures.join('\n')).toContain( + `Expected exactly one ${PACKAGE_NAME} or ${LEGACY_PACKAGE_NAME} ownership record; found 0.`, + ); + + const crossed = makeConsumer(revisionA, true, 'package', LEGACY_PACKAGE_NAME); + const crossedPath = join(crossed, OWNERSHIP_FIXTURE); + writeFileSync(crossedPath, JSON.stringify({ + ...JSON.parse(readText(crossedPath)), source: `${PACKAGE_NAME}@${revisionA}`, + })); + expect(verifyInstall(crossed).failures.join('\n')) + .toContain(`Package ownership source must be ${LEGACY_PACKAGE_NAME}@${revisionA}.`); + }); + + it('explains a case-only package identity mismatch without accepting it', () => { + const variant = 'GitHub/Squad/workflows'; + const root = makeConsumer(); + const ownershipPath = join(root, OWNERSHIP_FIXTURE); + const record = JSON.parse(readText(ownershipPath)); + writeFileSync(ownershipPath, JSON.stringify({ ...record, package: variant, source: `${variant}@${revisionA}` })); + const failure = verifyInstall(root).failures.join('\n'); + const state = `${OWNERSHIP_FIXTURE} declares ${variant}`; + expect(failure, state).toContain( + `Expected exactly one ${PACKAGE_NAME} or ${LEGACY_PACKAGE_NAME} ownership record; found 0.`, + ); + expect(failure, state).toContain( + `Found ${OWNERSHIP_FIXTURE} (${variant}): gh-aw records the package identity exactly as typed`, + ); + expect(failure, state).toContain(`Reinstall as ${PACKAGE_NAME}, then delete that record.`); + + writeFileSync(ownershipPath, JSON.stringify({ + ...record, package: 'attacker/squad/workflows', source: `attacker/squad/workflows@${revisionA}`, + })); + expect(verifyInstall(root).failures.join('\n'), 'an unrelated identity gets no case hint') + .not.toContain('exactly as typed'); + }); + it('rejects unsafe standalone contract shapes without filesystem access', () => { const contract = JSON.parse(readFileSync(join(process.cwd(), CONTRACT_SOURCE), 'utf8')); contract.skills[0].destination = '../outside'; diff --git a/test/gh-aw-review-workflow.test.ts b/test/gh-aw-review-workflow.test.ts index c5ef1f6c2..011244008 100644 --- a/test/gh-aw-review-workflow.test.ts +++ b/test/gh-aw-review-workflow.test.ts @@ -414,12 +414,25 @@ describe('gh-aw enforcing Squad reviewer', () => { }, 20000); it('keeps all consumer install surfaces on the isolated native package', () => { - for (const surface of [GUIDE, README, AGENT_GUIDE, SHARED_BOOTSTRAP]) { - expect(surface).toContain('bradygaster/squad/workflows@${SQUAD_SHA}'); - expect(surface).not.toMatch(/gh aw add \\\n\s+bradygaster\/squad\/workflows\/squad\.md/); + const fenced = (text: string) => [...text.matchAll(/^```[^\n]*\n([\s\S]*?)^```/gm)].map(match => match[1]); + for (const [name, surface, executable] of [ + ['docs guide', GUIDE, fenced(GUIDE)], + ['README.md', README, fenced(README)], + ['.github/agents.md', AGENT_GUIDE, fenced(AGENT_GUIDE)], + ['workflows/shared/squad.md', SHARED_BOOTSTRAP, [SHARED_BOOTSTRAP]], + ] as const) { + expect(surface, `${name} must install github/squad/workflows`) + .toContain('gh aw add "github/squad/workflows@${SQUAD_SHA}"'); + // Prose may name the legacy command for revisions that only verify it; executable blocks never use it. + for (const block of executable) { + expect(block, `${name} must not install the legacy identity`) + .not.toContain('gh aw add "bradygaster/squad/workflows@'); + } + expect(surface, `${name} must not install a single workflow`) + .not.toMatch(/gh aw add \\\r?\n\s+(?:github|bradygaster)\/squad\/workflows\/squad\.md/); } expect(DEMO).toContain("gh-aw guide's install command"); - expect(DEMO).not.toContain('gh aw add bradygaster/squad/workflows/squad.md@latest'); + expect(DEMO).not.toMatch(/gh aw add (?:github|bradygaster)\/squad\/workflows\/squad\.md@latest/); }); it('documents enforcement, explicit override, and the independent human approval requirement', () => { diff --git a/test/gh-aw-verify-resource.test.ts b/test/gh-aw-verify-resource.test.ts index f2418ad46..cef393de9 100644 --- a/test/gh-aw-verify-resource.test.ts +++ b/test/gh-aw-verify-resource.test.ts @@ -5,12 +5,15 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'nod import { dirname, join, resolve } from 'node:path'; import { CONTRACT_DESTINATION, + LEGACY_PACKAGE_NAMES, OWNERSHIP_DESTINATION, PACKAGE_NAME, + packageOwnershipDestination, } from '../workflows/shared/squad-install-verifier.mjs'; import { createFirstInstallFixture } from './helpers/gh-aw-install-fixture.js'; const revision = 'a'.repeat(40); +const [LEGACY_PACKAGE_NAME] = LEGACY_PACKAGE_NAMES; const verifier = resolve('workflows/shared/squad-install-verifier.mjs'); const workspaceRoot = resolve('.test-workspaces'); const roots: string[] = []; @@ -18,7 +21,9 @@ const workflowMutations: ReadonlyArray< readonly [string, (text: string, source: string) => string] > = [ ['owner', (text, source) => - text.replace(source, source.replace('bradygaster/', 'attacker/'))], + text.replace(source, source.replace('github/', 'attacker/'))], + ['legacy owner', (text, source) => + text.replace(source, source.replace(PACKAGE_NAME, LEGACY_PACKAGE_NAME))], ['repo', (text, source) => text.replace(source, source.replace('/squad/', '/other/'))], ['workflow', (text, source) => @@ -40,13 +45,16 @@ function write(root: string, path: string, content: string | Buffer): void { writeFileSync(destination, content); } -function makeConsumer(sourceBinding: 'workflow' | 'package' = 'workflow'): string { +function makeConsumer( + sourceBinding: 'workflow' | 'package' = 'workflow', + packageName: string = PACKAGE_NAME, +): string { mkdirSync(workspaceRoot, { recursive: true }); const root = mkdtempSync(join(workspaceRoot, 'gh-aw-verify-resource-')); roots.push(root); - const fixture = createFirstInstallFixture(revision, sourceBinding); + const fixture = createFirstInstallFixture(revision, sourceBinding, packageName); for (const [path, content] of fixture.consumerFiles) write(root, path, content); - write(root, OWNERSHIP_DESTINATION, `${JSON.stringify(fixture.provenance, null, 2)}\n`); + write(root, packageOwnershipDestination(packageName), `${JSON.stringify(fixture.provenance, null, 2)}\n`); return root; } @@ -85,6 +93,16 @@ describe('gh-aw: consumer resource verification', () => { }, ); + it.each(['workflow', 'package'] as const)( + 'exercises the shipped --verify-resource CLI against a legacy bradygaster/squad/workflows %s-bound install', + sourceBinding => { + const root = makeConsumer(sourceBinding, LEGACY_PACKAGE_NAME); + const result = runVerify(root, '.github/workflows/squad-review.md'); + expect(result.status, `legacy ${sourceBinding} binding: ${result.stderr}`).toBe(0); + expect(result.stdout.trim()).toMatch(/^[0-9a-f]{64}$/); + }, + ); + it('accepts only the documented final-newline variation', () => { const root = makeConsumer(); const destination = '.github/workflows/squad-review.md'; diff --git a/test/helpers/gh-aw-install-fixture.ts b/test/helpers/gh-aw-install-fixture.ts index 7900ab948..635a8d3c5 100644 --- a/test/helpers/gh-aw-install-fixture.ts +++ b/test/helpers/gh-aw-install-fixture.ts @@ -77,7 +77,7 @@ function sha256(content: Buffer): string { return createHash('sha256').update(content).digest('hex'); } -function withSource(content: Buffer, source: string, revision: string): Buffer { +function withSource(content: Buffer, source: string, revision: string, repository: string): Buffer { const text = content.toString('utf8'); const eol = text.includes('\r\n') ? '\r\n' : '\n'; const normalized = text.replace(/\r\n/g, '\n'); @@ -85,7 +85,7 @@ function withSource(content: Buffer, source: string, revision: string): Buffer { const end = normalized.indexOf(marker, 4); if (!normalized.startsWith('---\n') || end < 0) throw new Error(`Invalid workflow source: ${source}`); return Buffer.from( - `${normalized.slice(0, end)}\nsource: bradygaster/squad/${source}@${revision}${normalized.slice(end)}` + `${normalized.slice(0, end)}\nsource: ${repository}/${source}@${revision}${normalized.slice(end)}` .replace(/\n/g, eol), ); } @@ -104,13 +104,19 @@ export function githubFile(content: Buffer): { }; } +/** + * Builds the files a consumer has after `gh aw add @` plus strict + * compilation. `packageName` is the identity the consumer typed (gh-aw records it verbatim). + */ export function createFirstInstallFixture( revision: string, sourceBinding: 'workflow' | 'package' | 'mixed' = 'workflow', + packageName: string = PACKAGE_NAME, ): InstallFixture { - const cacheKey = `${revision}:${sourceBinding}`; + const cacheKey = `${revision}:${sourceBinding}:${packageName}`; const cached = fixtureCache.get(cacheKey); if (cached) return cloneFixture(cached); + const repository = packageName.replace(/\/workflows$/, ''); const root = process.cwd(); const manifestBytes = readFileSync(resolve(root, CONTRACT_SOURCE)); const manifest = JSON.parse(manifestBytes.toString('utf8')); @@ -127,7 +133,7 @@ export function createFirstInstallFixture( canonicalFiles.set(entry.source, canonical); const source = sourceBinding === 'package' || (sourceBinding === 'mixed' && index % 2 === 0) ? 'workflows' : entry.source; - const installed = withSource(canonical, source, revision); + const installed = withSource(canonical, source, revision, repository); consumerFiles.set(entry.destination, installed); const path = resolve(scratch, entry.destination); mkdirSync(dirname(path), { recursive: true }); @@ -167,8 +173,8 @@ export function createFirstInstallFixture( manifest, provenance: { schemaVersion: 1, - package: PACKAGE_NAME, - source: `${PACKAGE_NAME}@${revision}`, + package: packageName, + source: `${packageName}@${revision}`, resolvedCommit: revision, installer: 'gh-aw v0.91.5 test', files, diff --git a/test/helpers/refresh-squad-init-fixture.mjs b/test/helpers/refresh-squad-init-fixture.mjs index a04c8ad1e..54b2f4f0b 100644 --- a/test/helpers/refresh-squad-init-fixture.mjs +++ b/test/helpers/refresh-squad-init-fixture.mjs @@ -9,7 +9,7 @@ const revision = '5c662ba015ec4f99befa51b0e8ca4f2148b0497f'; const files = {}; for (const path of ['.github/actions/squad-init/action.yml', 'scripts/install.sh']) { const remote = JSON.parse(execFileSync('gh', [ - 'api', `repos/bradygaster/squad/contents/${path}?ref=${revision}`, + 'api', `repos/github/squad/contents/${path}?ref=${revision}`, ], { encoding: 'utf8' })); const bytes = Buffer.from(remote.content, 'base64'); const blob = createHash('sha1') @@ -22,7 +22,7 @@ for (const path of ['.github/actions/squad-init/action.yml', 'scripts/install.sh }; } writeFileSync('test/fixtures/squad-init-approved.json', `${JSON.stringify({ - repository: 'bradygaster/squad', + repository: 'github/squad', revision, files, }, null, 2)}\n`); diff --git a/test/squad-cli-latest.test.ts b/test/squad-cli-latest.test.ts index 5306eb3f8..5f228fee5 100644 --- a/test/squad-cli-latest.test.ts +++ b/test/squad-cli-latest.test.ts @@ -117,7 +117,7 @@ describe('Squad standalone activation latest-release default', () => { expect(install, 'workflows/shared/squad.md must retain the standalone install step').toBeDefined(); expect(install).toContain( - `uses: bradygaster/squad/.github/actions/squad-init@${revision}`, + `uses: github/squad/.github/actions/squad-init@${revision}`, ); expect(install).toContain("skip-init: 'true'"); expect(install, 'workflows/shared/squad.md must use the action default, not a version input') @@ -131,7 +131,7 @@ describe('Squad standalone activation latest-release default', () => { }); it('binds the exact independently retrieved action and installer to the activation pin', () => { - expect(approved.repository).toBe('bradygaster/squad'); + expect(approved.repository).toBe('github/squad'); expect(approved.revision).toBe(revision); const digests = { [actionPath]: 'c759c5425c710db59a1a33f4a59f53117a2f2e25c1f0df4e41f27507afd82aa8', diff --git a/workflows/README.md b/workflows/README.md index 8754c68ad..3fd4d7f50 100644 --- a/workflows/README.md +++ b/workflows/README.md @@ -4,9 +4,21 @@ This nested `gh-aw` package installs the complete Squad workflow bundle from one immutable commit: ```bash -gh aw add bradygaster/squad/workflows@<40-character-commit-sha> +gh aw add github/squad/workflows@<40-character-commit-sha> ``` +`github/squad/workflows` verifies only at Squad revisions whose +`workflows/squad-workflows.manifest.json` declares +`"package": "github/squad/workflows"`. Earlier revisions, including ones +committed after the repository moved to `github/squad`, declare and verify only +the legacy identity `bradygaster/squad/workflows`: install an older approved +revision under that identity, or ask the maintainers for a newer revision. +Revisions that declare `github/squad/workflows` still verify legacy +installations. When moving an existing installation to the new identity, +reinstall with `--force` and delete the superseded +`.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json` record that +gh-aw leaves behind. + The package contains exactly eight workflows, seventeen runtime resources, and the `gh-aw-enlistment` skill. After installation, follow the enlistment skill to materialize runtime resources, strictly compile every workflow, and verify the diff --git a/workflows/package/squad-bootstrap.md b/workflows/package/squad-bootstrap.md index cec5d6258..152a0ec51 100644 --- a/workflows/package/squad-bootstrap.md +++ b/workflows/package/squad-bootstrap.md @@ -153,7 +153,7 @@ pre-agent-steps: node --check "$path" >/dev/null } # BEGIN GENERATED RESOURCE DIGESTS - check_hash "$install_verifier" "d12cf4de38e716dd6d0bbb03fda22675477a4559573d3a8601b8f5b2f88f544e" + check_hash "$install_verifier" "eb6709d221e55f87d3fdb522a4f2bc4c21cc8bf96cb38908f93e2d5817f96735" check_hash "$cast_validator" "15f0d47917d269b5b967dc2a782a0eb761c39af7a92ddb64fed687a427070760" check_hash "$bootstrap_validator" "d552d041f006249bfaa67b10a75877f813796b7698c718ac7eb7cf16f3a72e31" # END GENERATED RESOURCE DIGESTS diff --git a/workflows/package/squad-deps-worker.md b/workflows/package/squad-deps-worker.md index 25a4dc758..fe3dd2d92 100644 --- a/workflows/package/squad-deps-worker.md +++ b/workflows/package/squad-deps-worker.md @@ -646,7 +646,7 @@ jobs: permission-contents: read - name: Install Squad CLI from standalone release id: squad-cli - uses: bradygaster/squad/.github/actions/squad-init@5c662ba015ec4f99befa51b0e8ca4f2148b0497f + uses: github/squad/.github/actions/squad-init@5c662ba015ec4f99befa51b0e8ca4f2148b0497f with: skip-init: "true" - name: Initialize Squad team diff --git a/workflows/package/squad-implement-worker.md b/workflows/package/squad-implement-worker.md index 349ca5394..7051318f9 100644 --- a/workflows/package/squad-implement-worker.md +++ b/workflows/package/squad-implement-worker.md @@ -781,7 +781,7 @@ jobs: permission-contents: read - name: Install Squad CLI from standalone release id: squad-cli - uses: bradygaster/squad/.github/actions/squad-init@5c662ba015ec4f99befa51b0e8ca4f2148b0497f + uses: github/squad/.github/actions/squad-init@5c662ba015ec4f99befa51b0e8ca4f2148b0497f with: skip-init: "true" - name: Initialize Squad team diff --git a/workflows/package/squad-retro.md b/workflows/package/squad-retro.md index 438ca7ff7..fb7392015 100644 --- a/workflows/package/squad-retro.md +++ b/workflows/package/squad-retro.md @@ -766,7 +766,7 @@ jobs: permission-contents: read - name: Install Squad CLI from standalone release id: squad-cli - uses: bradygaster/squad/.github/actions/squad-init@5c662ba015ec4f99befa51b0e8ca4f2148b0497f + uses: github/squad/.github/actions/squad-init@5c662ba015ec4f99befa51b0e8ca4f2148b0497f with: skip-init: "true" - name: Initialize Squad team diff --git a/workflows/package/squad.md b/workflows/package/squad.md index e9a82ee41..9a2018aa4 100644 --- a/workflows/package/squad.md +++ b/workflows/package/squad.md @@ -999,7 +999,7 @@ jobs: permission-contents: read - name: Install Squad CLI from standalone release id: squad-cli - uses: bradygaster/squad/.github/actions/squad-init@5c662ba015ec4f99befa51b0e8ca4f2148b0497f + uses: github/squad/.github/actions/squad-init@5c662ba015ec4f99befa51b0e8ca4f2148b0497f with: skip-init: "true" - name: Initialize Squad team diff --git a/workflows/shared/squad-install-verifier.mjs b/workflows/shared/squad-install-verifier.mjs index 1ddc96b9f..39bbcb73c 100644 --- a/workflows/shared/squad-install-verifier.mjs +++ b/workflows/shared/squad-install-verifier.mjs @@ -18,14 +18,24 @@ import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { createRequire } from 'node:module'; -export const PACKAGE_NAME = 'bradygaster/squad/workflows'; +export const PACKAGE_NAME = 'github/squad/workflows'; +// Identities that earlier Squad revisions declare as "package" in +// workflows/squad-workflows.manifest.json. PACKAGE_NAME verifies only at revisions whose manifest +// declares it. The cutoff is that declaration, not the repository move: revisions committed after +// the move to github/squad can still declare a legacy identity. gh-aw records the identity a +// consumer typed, so their ownership records, workflow `source:` bindings, and compiled lock +// metadata keep it until they reinstall as PACKAGE_NAME. +export const LEGACY_PACKAGE_NAMES = Object.freeze(['bradygaster/squad/workflows']); +export const ACCEPTED_PACKAGE_NAMES = Object.freeze([PACKAGE_NAME, ...LEGACY_PACKAGE_NAMES]); export const PACKAGE_MANIFEST = 'workflows/aw.yml'; export const CONTRACT_SOURCE = 'workflows/squad-workflows.manifest.json'; export const CONTRACT_DESTINATION = '.github/aw/squad-workflows.manifest.json'; export const MIN_GH_AW_VERSION = 'v0.91.5'; export const OWNERSHIP_ENTRY_COUNT = 30; -export const OWNERSHIP_DESTINATION = - '.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json'; +export const OWNERSHIP_DESTINATION = packageOwnershipDestination(PACKAGE_NAME); +export const LEGACY_OWNERSHIP_DESTINATIONS = Object.freeze( + LEGACY_PACKAGE_NAMES.map(packageOwnershipDestination), +); export const UNOWNED_MUTABLE_ROUTER_SKILL = '.github/skills/agentic-workflows/SKILL.md'; export const TRIGGER_PROBE = 'shared/squad-bootstrap-trigger-probe.json'; @@ -118,6 +128,25 @@ function sha256(content) { return createHash('sha256').update(content).digest('hex'); } +function assertAcceptedPackageName(packageName) { + if (!ACCEPTED_PACKAGE_NAMES.includes(packageName)) { + throw new Error(`Unsupported Squad package identity: ${JSON.stringify(packageName)}.`); + } +} + +// Mirrors gh-aw v0.91.5 stablePackageID: lowercase slug plus the first 12 hex digits of +// SHA-256 over the package identity exactly as installed. +export function packageOwnershipDestination(packageName) { + assertAcceptedPackageName(packageName); + const slug = packageName.toLowerCase().replace(/[/\\:@]/g, '-').replace(/^-+|-+$/g, ''); + return `.github/aw/packages/${slug}-${sha256(packageName).slice(0, 12)}.json`; +} + +function packageRepository(packageName) { + assertAcceptedPackageName(packageName); + return packageName.slice(0, -'/workflows'.length); +} + function validatePathSyntax(value, expected, label) { if (typeof value !== 'string' || value.length === 0) { throw new Error(`${label} must be a non-empty path.`); @@ -374,7 +403,7 @@ function workflowWithSource(content, name, revision, packageSource) { source: ${source}@${revision}${content.slice(end)}`; } -export function normalizeCompiledLock(content, revision) { +export function normalizeCompiledLock(content, revision, packageName = PACKAGE_NAME) { if (!REVISION_PATTERN.test(revision)) { throw new Error('Compiled lock revision must be a lowercase 40-character SHA.'); } @@ -386,13 +415,49 @@ export function normalizeCompiledLock(content, revision) { /^(# gh-aw-metadata: \{[^\n]*"frontmatter_hash":")([0-9a-f]{64})("[^\n]*\})$/m, ); if (!metadata) throw new Error('Compiled lock has missing or malformed gh-aw metadata.'); - return text + return canonicalizeLockPackageIdentity(text .replace(metadata[0], `${metadata[1]}${'0'.repeat(64)}${metadata[3]}`) .replace( /^(\s*-\s+cron:\s+)"[^"]+"(\s+# Friendly format: .+ \(scattered\))$/gm, '$1""$2', ) - .replaceAll(revision, LOCK_REVISION_PLACEHOLDER); + .replaceAll(revision, LOCK_REVISION_PLACEHOLDER), packageName); +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +// gh-aw renders the installed package identity into exactly these compiled-lock metadata +// lines; matching only after revision normalization binds them to the verified revision. +function lockPackageIdentityPatterns(repository) { + const owner = escapeRegExp(repository); + const path = '/workflows(?:/package/[a-z0-9-]+\\.md)?'; + const revision = LOCK_REVISION_PLACEHOLDER; + return [ + new RegExp(`^(# Source: )${owner}(${path}@${revision})$`, 'gm'), + new RegExp(`^(# To update this file, edit )${owner}(${path}@${revision} and run:)$`, 'gm'), + new RegExp(`^( +GH_AW_(?:WORKFLOW|INFO_FRONTMATTER)_SOURCE: ")${owner}(${path}@${revision}")$`, 'gm'), + new RegExp( + `^( +GH_AW_WORKFLOW_SOURCE_URL: "\\$\\{\\{ github\\.server_url \\}\\}/)${owner}(/blob/${revision}${path}")$`, + 'gm', + ), + ]; +} + +// Contract lock digests are computed for PACKAGE_NAME. A legacy-identity install compiles the +// same bytes except for those metadata lines, so translate only them before hashing. +function canonicalizeLockPackageIdentity(text, packageName) { + assertAcceptedPackageName(packageName); + if (packageName === PACKAGE_NAME) return text; + const canonical = packageRepository(PACKAGE_NAME); + if (lockPackageIdentityPatterns(canonical).some((pattern) => text.search(pattern) !== -1)) { + throw new Error( + `Compiled lock mixes ${PACKAGE_NAME} source metadata into a ${packageName} installation.`, + ); + } + return lockPackageIdentityPatterns(packageRepository(packageName)) + .reduce((current, pattern) => current.replace(pattern, `$1${canonical}$2`), text); } export function validateCompilerActionPins(content) { @@ -700,6 +765,7 @@ function findOwnershipRecord(root) { const packageDir = safePath(root, '.github/aw/packages'); if (!existsSync(packageDir)) throw new Error('Package ownership metadata is missing.'); const matches = []; + const caseVariants = []; for (const name of readdirSync(packageDir)) { if (!name.endsWith('.json')) continue; const relativePath = `.github/aw/packages/${name}`; @@ -709,10 +775,29 @@ function findOwnershipRecord(root) { } catch { continue; } - if (record.package === PACKAGE_NAME) matches.push({ path: relativePath, record }); + if (ACCEPTED_PACKAGE_NAMES.includes(record?.package)) { + matches.push({ path: relativePath, record }); + } else if (typeof record?.package === 'string' && ACCEPTED_PACKAGE_NAMES + .some(accepted => accepted.toLowerCase() === record.package.toLowerCase())) { + caseVariants.push(`${relativePath} (${record.package})`); + } + } + if (matches.length > 1) { + throw new Error( + `Expected exactly one Squad package ownership record; found ${matches.length}: ` + + `${matches.map(({ path, record }) => `${path} (${record.package})`).join(', ')}. ` + + 'Keep only the record whose identity matches the installed workflow source bindings; ' + + `after reinstalling as ${PACKAGE_NAME}, delete ${LEGACY_OWNERSHIP_DESTINATIONS.join(', ')}.`, + ); } if (matches.length !== 1) { - throw new Error(`Expected exactly one ${PACKAGE_NAME} ownership record; found ${matches.length}.`); + // Identity matching stays exact; this only explains a case-only mismatch. + const hint = caseVariants.length === 0 ? '' : ` Found ${caseVariants.join(', ')}: gh-aw records the ` + + 'package identity exactly as typed and Squad matches it case-sensitively. ' + + `Reinstall as ${PACKAGE_NAME}, then delete that record.`; + throw new Error( + `Expected exactly one ${ACCEPTED_PACKAGE_NAMES.join(' or ')} ownership record; found 0.${hint}`, + ); } return matches[0].record; } @@ -728,7 +813,7 @@ function expectedOwnership(contract) { function verifyOwnership(root, contract, expectedRevision) { const record = findOwnershipRecord(root); assertKeys(record, ['schemaVersion', 'package', 'source', 'resolvedCommit', 'installer', 'files'], 'Package ownership'); - if (record.schemaVersion !== 1 || record.package !== PACKAGE_NAME) { + if (record.schemaVersion !== 1 || !ACCEPTED_PACKAGE_NAMES.includes(record.package)) { throw new Error('Package ownership identity is invalid.'); } if (!REVISION_PATTERN.test(record.resolvedCommit)) { @@ -737,8 +822,8 @@ function verifyOwnership(root, contract, expectedRevision) { if (expectedRevision && record.resolvedCommit !== expectedRevision) { throw new Error(`Package revision mismatch: expected ${expectedRevision}, got ${record.resolvedCommit}.`); } - if (record.source !== `${PACKAGE_NAME}@${record.resolvedCommit}`) { - throw new Error(`Package ownership source must be ${PACKAGE_NAME}@${record.resolvedCommit}.`); + if (record.source !== `${record.package}@${record.resolvedCommit}`) { + throw new Error(`Package ownership source must be ${record.package}@${record.resolvedCommit}.`); } if (typeof record.installer !== 'string' || !/^gh-aw v0\.91\.5(?:\s|$)/.test(record.installer)) { throw new Error('Package ownership installer must be gh-aw v0.91.5.'); @@ -760,7 +845,7 @@ function verifyOwnership(root, contract, expectedRevision) { throw new Error(`Package ownership digest is stale for ${owned.destination}.`); } } - return record.resolvedCommit; + return { revision: record.resolvedCommit, packageName: record.package }; } function digestMatchesWithFinalNewlineTolerance(content, expectedDigest) { @@ -768,7 +853,7 @@ function digestMatchesWithFinalNewlineTolerance(content, expectedDigest) { .some(candidate => sha256(Buffer.from(candidate)) === expectedDigest); } -function verifyWorkflowSourceBinding(root, entry, revision, lockEntry = entry) { +function verifyWorkflowSourceBinding(root, entry, revision, lockEntry, packageName) { const installed = readRequired(root, entry.destination); const text = installed.toString('utf8'); const lines = text.split('\n'); @@ -776,9 +861,10 @@ function verifyWorkflowSourceBinding(root, entry, revision, lockEntry = entry) { const sourceIndexes = lines .map((line, index) => line.startsWith('source:') ? index : -1) .filter(index => index >= 0); + // Only the identity bound by the verified ownership record is accepted; never mix identities. const sourceBindings = new Map([ - [`source: ${PACKAGE_NAME}@${revision}`, lockEntry.package_lock_sha256], - [`source: bradygaster/squad/${entry.source}@${revision}`, lockEntry.lock_sha256], + [`source: ${packageName}@${revision}`, lockEntry.package_lock_sha256], + [`source: ${packageRepository(packageName)}/${entry.source}@${revision}`, lockEntry.lock_sha256], ]); const sourceIndex = sourceIndexes[0]; const sourceBinding = lines[sourceIndex]; @@ -815,12 +901,14 @@ function configuredFailureExpiry(root) { return hours; } -function verifyInstalledBytes(root, contract, revision) { +function verifyInstalledBytes(root, contract, revision, packageName) { const expiry = configuredFailureExpiry(root); const variant = contract.lock_variants.find(entry => entry.action_failure_issue_expires === expiry); const lockDigests = new Map(); for (const [index, entry] of contract.workflows.entries()) { - const verified = verifyWorkflowSourceBinding(root, entry, revision, variant?.workflows[index] ?? entry); + const verified = verifyWorkflowSourceBinding( + root, entry, revision, variant?.workflows[index] ?? entry, packageName, + ); // Select from the verified source, never accept whichever lock digest happens to match. lockDigests.set(entry.name, verified.lockDigest); } @@ -838,7 +926,7 @@ function verifyInstalledBytes(root, contract, revision) { } for (const entry of contract.workflows) readRequired(root, entry.lock); for (const entry of contract.workflows) { - const normalized = normalizeCompiledLock(readRequired(root, entry.lock), revision); + const normalized = normalizeCompiledLock(readRequired(root, entry.lock), revision, packageName); const observedDigest = sha256(normalized); const expectedDigest = lockDigests.get(entry.name); if (observedDigest !== expectedDigest) { @@ -965,13 +1053,14 @@ function spawnChecked(command, args, cwd, options = {}) { export function verifyInstall(root, { expectedRevision = '', strictCompile = false } = {}) { const failures = []; let revision = expectedRevision; + let packageName; try { if (expectedRevision && !REVISION_PATTERN.test(expectedRevision)) { throw new Error('Expected revision must be a lowercase 40-character SHA.'); } const { contract } = parseInstalledContract(root); - revision = verifyOwnership(root, contract, expectedRevision); - verifyInstalledBytes(root, contract, revision); + ({ revision, packageName } = verifyOwnership(root, contract, expectedRevision)); + verifyInstalledBytes(root, contract, revision, packageName); verifyUnownedMutableRouterAbsent(root); verifyTriggerNamespace(root); verifyTriggerProbe(root, revision); @@ -979,7 +1068,7 @@ export function verifyInstall(root, { expectedRevision = '', strictCompile = fal } catch (error) { failures.push(error instanceof Error ? error.message : String(error)); } - return { failures, revision }; + return { failures, revision, packageName }; } export function verifyStagedInstall(root, { expectedRevision = '', stageOwnership = false } = {}) { @@ -988,13 +1077,17 @@ export function verifyStagedInstall(root, { expectedRevision = '', stageOwnershi try { const { contract } = parseInstalledContract(root); // Only the native package's exact metadata path may bypass consumer ignore rules. - const ownership = readRequired(root, OWNERSHIP_DESTINATION); - if (JSON.parse(ownership).package !== PACKAGE_NAME) { - throw new Error(`Package ownership identity is invalid: ${OWNERSHIP_DESTINATION}`); + const ownershipDestination = packageOwnershipDestination(result.packageName); + const ownership = readRequired(root, ownershipDestination); + if (JSON.parse(ownership).package !== result.packageName) { + throw new Error(`Package ownership identity is invalid: ${ownershipDestination}`); } + const superseded = ACCEPTED_PACKAGE_NAMES + .filter(name => name !== result.packageName) + .map(packageOwnershipDestination); const required = new Set([ CONTRACT_DESTINATION, - OWNERSHIP_DESTINATION, + ownershipDestination, ...contract.workflows.flatMap(entry => [entry.destination, entry.lock]), ...contract.shared_runtime.flatMap(entry => [entry.package_destination, entry.destination]), ...contract.skills.map(entry => entry.destination), @@ -1003,10 +1096,10 @@ export function verifyStagedInstall(root, { expectedRevision = '', stageOwnershi const expected = new Map([...required].map(path => [path, fileDigest(root, path)])); if (stageOwnership) { const ignored = spawnChecked('git', [ - 'ls-files', '--others', '--ignored', '--exclude-standard', '-z', '--', OWNERSHIP_DESTINATION, + 'ls-files', '--others', '--ignored', '--exclude-standard', '-z', '--', ownershipDestination, ], root).stdout.split('\0'); - if (ignored.includes(OWNERSHIP_DESTINATION)) { - spawnChecked('git', ['add', '--force', '--', OWNERSHIP_DESTINATION], root); + if (ignored.includes(ownershipDestination)) { + spawnChecked('git', ['add', '--force', '--', ownershipDestination], root); } } // Snapshot the index, not HEAD or the working tree, including unchanged tracked files. @@ -1014,7 +1107,7 @@ export function verifyStagedInstall(root, { expectedRevision = '', stageOwnershi // Exact allowlisted paths bound output; no recursion into unrelated or substituted trees. const entries = spawnChecked( 'git', - ['ls-tree', '-z', tree, '--', ...required, REPO_CONFIG_PATH, UNOWNED_MUTABLE_ROUTER_SKILL], + ['ls-tree', '-z', tree, '--', ...required, REPO_CONFIG_PATH, UNOWNED_MUTABLE_ROUTER_SKILL, ...superseded], root, ).stdout .split('\0').filter(Boolean); @@ -1042,6 +1135,18 @@ export function verifyStagedInstall(root, { expectedRevision = '', stageOwnershi if (!required.has(REPO_CONFIG_PATH) && staged.has(REPO_CONFIG_PATH)) { throw new Error(`Unverified compiler configuration remains in staged tree: ${REPO_CONFIG_PATH}`); } + for (const path of superseded) { + if (!staged.has(path)) continue; + // The skill's deletion gate permits deleting only legacy records, so a staged canonical + // record in a legacy-owned install must move forward instead of being deleted. + throw new Error(path === OWNERSHIP_DESTINATION + ? `Squad package ownership record ${path} (${PACKAGE_NAME}) remains in the staged tree of this ` + + `${result.packageName} installation; do not delete it. Reinstall with ` + + `gh aw add ${PACKAGE_NAME}@${result.revision} --force, delete ${ownershipDestination}, ` + + 'and stage both changes before commit/push.' + : `Superseded Squad package ownership record remains in staged tree: ${path}; ` + + 'delete it and stage the deletion before commit/push.'); + } } catch (error) { result.failures.push(error instanceof Error ? error.message : String(error)); } @@ -1064,8 +1169,8 @@ export function verifyResource(root, destination) { .find((candidate) => candidate.destination === destination); if (!entry) throw new Error(`Resource is not registered in the trusted Squad contract: ${destination}`); if ('source_sha256' in entry) { - const revision = verifyOwnership(root, contract); - return verifyWorkflowSourceBinding(root, entry, revision).digest; + const { revision, packageName } = verifyOwnership(root, contract); + return verifyWorkflowSourceBinding(root, entry, revision, entry, packageName).digest; } const actual = fileDigest(root, destination); if (actual !== entry.sha256) throw new Error(`Squad resource digest mismatch for ${destination}.`); @@ -1112,7 +1217,7 @@ export function writeLocalTestOwnership(root, revision) { ...entry, sha256: fileDigest(root, entry.destination), })); - writePath(root, '.github/aw/packages/bradygaster-squad-workflows-local-test.json', stableJson({ + writePath(root, '.github/aw/packages/github-squad-workflows-local-test.json', stableJson({ schemaVersion: 1, package: PACKAGE_NAME, source: `${PACKAGE_NAME}@${revision}`, @@ -1127,12 +1232,22 @@ function recoveryMessage(revision) { return [ 'Safe recovery:', ` gh aw add ${PACKAGE_NAME}@${ref} --force`, + ...LEGACY_OWNERSHIP_DESTINATIONS.map(path => ` rm -f ${path}`), ` rm -f ${UNOWNED_MUTABLE_ROUTER_SKILL}`, ' gh aw compile --strict', ' node .github/workflows/shared/squad-install-verifier.mjs --verify-install --strict-compile', ].join('\n'); } +function legacyIdentityNotice(packageName, revision) { + return [ + `Notice: verified under the legacy ${packageName} package identity, which remains supported.`, + `To move to ${PACKAGE_NAME}, follow the gh-aw guide's Upgrading steps, starting with:`, + ` gh aw add ${PACKAGE_NAME}@${revision} --force`, + ...LEGACY_OWNERSHIP_DESTINATIONS.map(path => ` rm -f ${path}`), + ].join('\n'); +} + function optionValue(args, option) { const index = args.indexOf(option); return index >= 0 ? args[index + 1] : ''; @@ -1181,6 +1296,7 @@ export function run(argv = process.argv.slice(2), cwd = process.cwd()) { return 1; } console.log(`Squad gh-aw installation verified at ${result.revision}.`); + if (result.packageName !== PACKAGE_NAME) console.error(legacyIdentityNotice(result.packageName, result.revision)); return 0; } if (argv.includes('--verify-staged-install')) { diff --git a/workflows/shared/squad.md b/workflows/shared/squad.md index b501b99be..7fb7efac1 100644 --- a/workflows/shared/squad.md +++ b/workflows/shared/squad.md @@ -1,10 +1,10 @@ --- # Squad Bootstrap Component — installs and initializes Squad -# (https://github.com/bradygaster/squad) in the activation job, then hands off +# (https://github.com/github/squad) in the activation job, then hands off # the generated team state to the agent job. This is the DISTRIBUTION version, # living under workflows/shared/ so users can pull the standard stack from one # immutable nested native package: -# gh aw add "bradygaster/squad/workflows@${SQUAD_SHA}" +# gh aw add "github/squad/workflows@${SQUAD_SHA}" # # Adapted from Peli de Halleux's gh-aw integration: # https://github.com/github/gh-aw/blob/main/.github/workflows/shared/squad.md @@ -537,7 +537,7 @@ jobs: - name: Install Squad CLI from standalone release id: squad-cli - uses: bradygaster/squad/.github/actions/squad-init@5c662ba015ec4f99befa51b0e8ca4f2148b0497f + uses: github/squad/.github/actions/squad-init@5c662ba015ec4f99befa51b0e8ca4f2148b0497f with: skip-init: 'true' diff --git a/workflows/skills/gh-aw-enlistment/SKILL.md b/workflows/skills/gh-aw-enlistment/SKILL.md index 0a02b6d01..91043f57a 100644 --- a/workflows/skills/gh-aw-enlistment/SKILL.md +++ b/workflows/skills/gh-aw-enlistment/SKILL.md @@ -25,7 +25,7 @@ bootstrap pull request** — never an auto-merge. This skill **operationalizes** the supported bootstrap path. It does not summarize it: each step below is a gate with explicit success evidence and **STOP conditions**. The authoritative source is `docs/src/content/docs/guide/gh-aw.md` (mirrored at -`https://bradygaster.github.io/squad/docs/guide/gh-aw/`). If that guide and this +`https://github.github.com/squad/docs/guide/gh-aw/`). If that guide and this skill ever disagree, the guide wins — re-read it before proceeding. GitHub Issues are a hard prerequisite: `/squad` commands arrive as issue @@ -193,10 +193,34 @@ published release guidance, then set it once: echo "STOP: SQUAD_SHA must be the exact 40-character commit SHA, not a branch name or shortened hash." >&2 exit 1 } -gh aw add "bradygaster/squad/workflows@${SQUAD_SHA}" +# squad-package-identity-start +squad_package="$(gh api --hostname github.com -H 'Accept: application/vnd.github.raw+json' \ + "repos/github/squad/contents/workflows/squad-workflows.manifest.json?ref=${SQUAD_SHA}" \ + --jq '.package')" || squad_package="" +test "${squad_package}" = "github/squad/workflows" || { + if [ -z "${squad_package}" ]; then + echo "STOP: cannot read workflows/squad-workflows.manifest.json from github/squad at ${SQUAD_SHA}." >&2 + echo "Check gh auth status --hostname github.com and network access, and confirm SQUAD_SHA is a github/squad commit that has this manifest, then retry." >&2 + else + echo "STOP: Squad ${SQUAD_SHA} does not declare the github/squad/workflows package (found '${squad_package}')." >&2 + echo "Install a revision that declares bradygaster/squad/workflows under that identity, or ask the Squad maintainers for a newer revision." >&2 + fi + exit 1 +} +# squad-package-identity-end +gh aw add "github/squad/workflows@${SQUAD_SHA}" rm -f .github/skills/agentic-workflows/SKILL.md ``` +`github/squad/workflows` verifies only at Squad revisions whose +`workflows/squad-workflows.manifest.json` declares +`"package": "github/squad/workflows"`. The identity check above reads that +manifest at `SQUAD_SHA` and stops on any other revision. An older +maintainer-approved revision declares `bradygaster/squad/workflows`: install it +under that identity, because its verifier accepts no other, or ask the Squad +maintainers for a newer revision. Such a revision's safe-update report names +the same squad-init action as `bradygaster/squad/.github/actions/squad-init`. + The nested `workflows/aw.yml` is the only supported distribution registration. It isolates package auto-discovery from unrelated repository skills and agents, and installs exactly eight workflows, seventeen runtime resources, and one @@ -226,6 +250,61 @@ that exact file after every `gh aw add`. Keep the verifier requires its exact package bytes. Do not adopt or vendor the rest of gh-aw's generic scaffold. +Revisions whose `workflows/squad-workflows.manifest.json` declares +`"package": "github/squad/workflows"` verify that identity and the legacy +`bradygaster/squad/workflows` identity. Earlier revisions, including ones +committed after the repository moved to `github/squad`, declare and verify only +`bradygaster/squad/workflows`, and installations made from them carry it in +their ownership record, workflow `source:` lines, and locks. Upgrade such an +installation to a revision that declares `github/squad/workflows` with the block +below. gh-aw keeps the superseded +`.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json` after the +forced reinstall, so the block deletes exactly that file before verifying; the +verifier fails closed while both ownership records exist. That first upgrade +also makes `gh aw add` report one safe-update change: action +`github/squad/.github/actions/squad-init` added and +`bradygaster/squad/.github/actions/squad-init` removed. It is the same +squad-init action under the repository's new owner; list it in the PR's +security review note. **STOP** on any other new secret or action. Until an +installation moves, `--verify-install` still passes and prints a notice naming +the legacy identity. To stay on a revision that declares +`bradygaster/squad/workflows`, replace the identity check and the `gh aw add` +line below with `gh aw add "bradygaster/squad/workflows@${SQUAD_SHA}" --force`, +and skip the second `rm -f`: that ownership record is the only one such an +installation has. + +```bash +: "${SQUAD_SHA:?STOP: set SQUAD_SHA to an explicit, maintainer-approved 40-character Squad commit SHA before upgrading.}" +[[ "${SQUAD_SHA}" =~ ^[0-9a-f]{40}$ ]] || { + echo "STOP: SQUAD_SHA must be the exact 40-character commit SHA, not a branch name or shortened hash." >&2 + exit 1 +} +squad_package="$(gh api --hostname github.com -H 'Accept: application/vnd.github.raw+json' \ + "repos/github/squad/contents/workflows/squad-workflows.manifest.json?ref=${SQUAD_SHA}" \ + --jq '.package')" || squad_package="" +test "${squad_package}" = "github/squad/workflows" || { + if [ -z "${squad_package}" ]; then + echo "STOP: cannot read workflows/squad-workflows.manifest.json from github/squad at ${SQUAD_SHA}." >&2 + echo "Check gh auth status --hostname github.com and network access, and confirm SQUAD_SHA is a github/squad commit that has this manifest, then retry." >&2 + else + echo "STOP: Squad ${SQUAD_SHA} does not declare the github/squad/workflows package (found '${squad_package}')." >&2 + echo "Install a revision that declares bradygaster/squad/workflows under that identity, or ask the Squad maintainers for a newer revision." >&2 + fi + exit 1 +} +gh aw add "github/squad/workflows@${SQUAD_SHA}" --force +rm -f .github/skills/agentic-workflows/SKILL.md +rm -f .github/aw/packages/bradygaster-squad-workflows-3632054824e8.json +node .github/workflows/shared/squad-install-verifier.mjs --materialize-runtime +gh aw compile --strict +node .github/workflows/shared/squad-install-verifier.mjs \ + --verify-install \ + --source-revision "${SQUAD_SHA}" \ + --strict-compile +``` + +Apply step 5's two-warning check to the compile output above, then stage and verify as in step 7. + Report/proposal-only is the default. Ordinary fixes require the explicit `"squadRetroAutoImplement": "allow"` setting in `.squad/config.json`; five action issues and three dispatches per wake-up remain separate caps. @@ -247,9 +326,13 @@ On a clean repo, `gh aw add` reports these expected safe updates and **nothing e - Restricted secrets: **`SQUAD_GITHUB_APP_PRIVATE_KEY`** and **`SQUAD_GITHUB_TOKEN`** -- Action: **`bradygaster/squad/.github/actions/squad-init`** +- Action: **`github/squad/.github/actions/squad-init`** +At a revision whose manifest declares `bradygaster/squad/workflows`, the same +action is reported as `bradygaster/squad/.github/actions/squad-init`. Accept +that name only for such a revision. + > **These are referenced names, not prerequisites.** `gh aw add` lists the secrets > the workflows *reference* so you can approve that surface — it is not asking you > to supply them. Both secrets are optional, they need not exist, and neither is @@ -349,7 +432,8 @@ node .github/workflows/shared/squad-install-verifier.mjs \ git diff --cached --stat unexpected_deletions="$( git diff --cached --diff-filter=D --name-only | - grep -vxF '.github/skills/agentic-workflows/SKILL.md' || true + grep -vxF -e '.github/skills/agentic-workflows/SKILL.md' \ + -e '.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json' || true )" test -z "${unexpected_deletions}" || { printf 'STOP: unexpected staged deletions:\n%s\n' "${unexpected_deletions}" >&2 @@ -357,9 +441,11 @@ test -z "${unexpected_deletions}" || { } ``` -- The only permitted staged deletion is +- The only permitted staged deletions are `.github/skills/agentic-workflows/SKILL.md`, when upgrading a repository that - previously committed gh-aw's mutable router. + previously committed gh-aw's mutable router, and + `.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json`, when moving + an existing installation off the legacy `bradygaster/squad/workflows` identity. - **STOP** if the staged diff shows any other **unexpected deletions**, **unexpected secrets**, edits to **unrelated files**, or committed **log/diagnostic output**. Re-scope with explicit `git add -- ` — never `git add .`, `git add -A`, or `git commit -a`. @@ -498,7 +584,20 @@ git switch -c chore/squad-gh-aw-bootstrap echo "STOP: SQUAD_SHA must be an explicit, maintainer-approved 40-character lowercase hex commit SHA." >&2 exit 1 } -gh aw add "bradygaster/squad/workflows@${SQUAD_SHA}" +squad_package="$(gh api --hostname github.com -H 'Accept: application/vnd.github.raw+json' \ + "repos/github/squad/contents/workflows/squad-workflows.manifest.json?ref=${SQUAD_SHA}" \ + --jq '.package')" || squad_package="" +test "${squad_package}" = "github/squad/workflows" || { + if [ -z "${squad_package}" ]; then + echo "STOP: cannot read workflows/squad-workflows.manifest.json from github/squad at ${SQUAD_SHA}." >&2 + echo "Check gh auth status --hostname github.com and network access, and confirm SQUAD_SHA is a github/squad commit that has this manifest, then retry." >&2 + else + echo "STOP: Squad ${SQUAD_SHA} does not declare the github/squad/workflows package (found '${squad_package}')." >&2 + echo "Install a revision that declares bradygaster/squad/workflows under that identity, or ask the Squad maintainers for a newer revision." >&2 + fi + exit 1 +} +gh aw add "github/squad/workflows@${SQUAD_SHA}" rm -f .github/skills/agentic-workflows/SKILL.md # Safe-update report shows ONLY the two documented secrets + squad-init → approve once @@ -566,7 +665,9 @@ gh pr merge --squash # auto-merge before human review. NEVER. `.github/skills/agentic-workflows/SKILL.md`; keep the exact Squad-owned `.github/skills/gh-aw-enlistment/SKILL.md`. - ❌ **Approving unknown safe updates.** Approve ONLY `SQUAD_GITHUB_APP_PRIVATE_KEY`, - `SQUAD_GITHUB_TOKEN`, and `bradygaster/squad/.github/actions/squad-init`. Anything + `SQUAD_GITHUB_TOKEN`, and `github/squad/.github/actions/squad-init` (reported + as `bradygaster/squad/.github/actions/squad-init` only at a revision whose + manifest declares `bradygaster/squad/workflows`). Anything else is a STOP. - ❌ **Treating `--approve` as the final compile.** Always finish with a plain `gh aw compile --strict` (no `--approve`). diff --git a/workflows/squad-bootstrap.md b/workflows/squad-bootstrap.md index 4d3066197..f5635a5d6 100644 --- a/workflows/squad-bootstrap.md +++ b/workflows/squad-bootstrap.md @@ -160,7 +160,7 @@ pre-agent-steps: node --check "$path" >/dev/null } # BEGIN GENERATED RESOURCE DIGESTS - check_hash "$install_verifier" "d12cf4de38e716dd6d0bbb03fda22675477a4559573d3a8601b8f5b2f88f544e" + check_hash "$install_verifier" "eb6709d221e55f87d3fdb522a4f2bc4c21cc8bf96cb38908f93e2d5817f96735" check_hash "$cast_validator" "15f0d47917d269b5b967dc2a782a0eb761c39af7a92ddb64fed687a427070760" check_hash "$bootstrap_validator" "d552d041f006249bfaa67b10a75877f813796b7698c718ac7eb7cf16f3a72e31" # END GENERATED RESOURCE DIGESTS diff --git a/workflows/squad-workflows.manifest.json b/workflows/squad-workflows.manifest.json index bddad02ad..5041fd40e 100644 --- a/workflows/squad-workflows.manifest.json +++ b/workflows/squad-workflows.manifest.json @@ -1,6 +1,6 @@ { "schema_version": 2, - "package": "bradygaster/squad/workflows", + "package": "github/squad/workflows", "manifest": "workflows/aw.yml", "minimum_gh_aw_version": "v0.91.5", "revision_policy": { @@ -15,18 +15,18 @@ "source": "workflows/package/squad.md", "destination": ".github/workflows/squad.md", "lock": ".github/workflows/squad.lock.yml", - "source_sha256": "0720c948a2513ac7994bc5fb65cbdb875da7c6ae3a2a4f5fda4842bab0098b44", - "lock_sha256": "930f5583a0931706164b4d48f770faeb0330ad12b29880fa2667f3cd37c2ad95", - "package_lock_sha256": "b3de24942556d64ab1c149762c7d46cf45cb576a86a25b549337978cfba9e22e" + "source_sha256": "5a15a269c36747553ce5099ebaddd90bc7d832d6855641a90368999a12f7941c", + "lock_sha256": "fc8e1c1b9006fddb70edef284b73ab4a5e52e34fb68c1e2cacf24087a9c8c570", + "package_lock_sha256": "791c6749c86c5f4d28912f40a789a0c62a2801c46a1cd230ef0a8098a7403ccf" }, { "name": "squad-implement-worker", "source": "workflows/package/squad-implement-worker.md", "destination": ".github/workflows/squad-implement-worker.md", "lock": ".github/workflows/squad-implement-worker.lock.yml", - "source_sha256": "c18889c0fb2dc5a389c04e745bf8398993f07da8a9a1c6511101f3902ed326cd", - "lock_sha256": "079c5ee80259dc9a51f6dd8a3793f8f26586b4adda14078e40b22db7f7f7a73e", - "package_lock_sha256": "bc7611b14ad4cda7b826de290091a36d4e6b8ba05feea6670c75f4bbf3c5e64e" + "source_sha256": "c4b4b8cb9996613df8a0ac16d9438598b9c3ecb81759a83422dafe6063a3c5ef", + "lock_sha256": "38dbdd2e91401efc7b014318dfba13483a5db3e0f8e08989ad97459fe7fe51a1", + "package_lock_sha256": "865a3dbf0b9ff9bc9c80c74783c0a78a8f426d94103dd9723a7b213bc70331c3" }, { "name": "squad-review", @@ -34,26 +34,26 @@ "destination": ".github/workflows/squad-review.md", "lock": ".github/workflows/squad-review.lock.yml", "source_sha256": "f620707fd8d117d235c8c3ea1b90fdb198ebbb99a9fe87363b5229674abebc57", - "lock_sha256": "218a034ef788e06951cf8d511679364e37cafb2c470cb0a29d605b5aeb477b91", - "package_lock_sha256": "befaddd9feecfe436722f0e36127756dd9b9bcdbd9a73758dd9c0dc228864dd9" + "lock_sha256": "888a1235f3bd917b7a6ed18dbbf1cb0063a422ff7284982b3b77cc2ac07ad90e", + "package_lock_sha256": "f5fa30aec22fe08985c544c741078ea8168cc69ca581b9aaaf95b08a75361f48" }, { "name": "squad-deps-worker", "source": "workflows/package/squad-deps-worker.md", "destination": ".github/workflows/squad-deps-worker.md", "lock": ".github/workflows/squad-deps-worker.lock.yml", - "source_sha256": "c0de841e77708fc07cb126dc2fe3899a7e9c955b756e68ef21c5ad927621f853", - "lock_sha256": "2462182e6392b145d40853d1687d0bfdb83ac31757bbb28b3786d533160c8921", - "package_lock_sha256": "0e9e12f7c7b0786e7bec4bbc6ae8b334488a5c62defa37a9f5c13c649b39daad" + "source_sha256": "55618b94fbd7f5331b2ae146b3a09a824f38ae280ffdaa9f9fd5dddde0a99161", + "lock_sha256": "cff4edf52ef5056200d72a6cf7cd847982015a113a0b338c7a83cc33526c258b", + "package_lock_sha256": "208041ffbbfb2d278ddaa4d030b4a175d8d06cb176002c707b2edeb7801e6116" }, { "name": "squad-retro", "source": "workflows/package/squad-retro.md", "destination": ".github/workflows/squad-retro.md", "lock": ".github/workflows/squad-retro.lock.yml", - "source_sha256": "d5d34a97409b2fec7be2606ce26eeec5fa675891956331669342155118c259d1", - "lock_sha256": "ad730dedf19602e9f8ca3680b91ab9b992c4b14a2d04feb619576c684ec83376", - "package_lock_sha256": "cabfa606daae655f9fd2ebbb5baeda51767cb33a3b03dffe7f3cf4d1316b1c4a" + "source_sha256": "fcf9ab360c98d838ae2150674a4e8ae2e11db427d5dfe8ed87ead4c29555121d", + "lock_sha256": "c0355db76ce43bd9e8274fd38dddafc2e9a0b24865e04f474823494593da2b27", + "package_lock_sha256": "17b5739c1ce46f5111eef8447413682756204e4c91fbd7cfccddf3c59e2683eb" }, { "name": "squad-improvement-worker", @@ -61,17 +61,17 @@ "destination": ".github/workflows/squad-improvement-worker.md", "lock": ".github/workflows/squad-improvement-worker.lock.yml", "source_sha256": "1215304b71346cc13939a9b7f3e186bc20581d67ddba5ee47b21a9d4bdbc67c7", - "lock_sha256": "ce56c2428fb85a35b6b36e6f86f06ede07ca29f18a7e255b41292189287e95d2", - "package_lock_sha256": "1f0b2c4e528d08ca40b66dadefc321451fdade7109c0063b5eb00557b44f30a0" + "lock_sha256": "183010a926f4e01085723103bbaf77071f98f593ff28dd886ef3e5cb9a151283", + "package_lock_sha256": "2da0865155a544ce8b9d5ba3a7af8d3b26169dc5fbbe3927249b4e1978a17e3c" }, { "name": "squad-bootstrap", "source": "workflows/package/squad-bootstrap.md", "destination": ".github/workflows/squad-bootstrap.md", "lock": ".github/workflows/squad-bootstrap.lock.yml", - "source_sha256": "134046b4aab2ee0eee8a4f0a59f53847afa6387bbccde13b76f1308dad8e649f", - "lock_sha256": "49cce70f353f069b70016f23c51c9a2dee36af5276d8847221fd96a671969975", - "package_lock_sha256": "794f325069499495b132d61b30e40a4ef92c67a70201e6e42704a84f58ca6729" + "source_sha256": "fa4e4eef298dc7162ce44c1d33b9750bce521172867b247f933a943e0aebc88f", + "lock_sha256": "91b1ce3fee7ca3f195536fe27446f2a19477a9023d0416db86e92a3e097b7426", + "package_lock_sha256": "c064a6c063a909e9c18b58bf491d4a84bf13e52e80402e5f0728801b96d5884f" }, { "name": "squad-command-router", @@ -79,8 +79,8 @@ "destination": ".github/workflows/squad-command-router.md", "lock": ".github/workflows/squad-command-router.lock.yml", "source_sha256": "34d495b2e899dbbfb270135c3d6bc531c510eef91d705a91821e4a5b85f6b92c", - "lock_sha256": "04c2c877eb9e5e594b5b1d804e5fdfd19c9efdf0d0a76fa214cdbb614197fa6c", - "package_lock_sha256": "9a6d72873cc8d39ca2de9854c1e4eaac88818fde755a398a8c7ca59b9e3a88c0" + "lock_sha256": "8e1658e527380399105c6add9868b3696005ab9a2281708d7ce822fcaf964ee1", + "package_lock_sha256": "ae8f8a656e4e3451e37259c5ae847e05a23b45c3f23f5dc0f105e1127f6b465c" } ], "lock_variants": [ @@ -89,43 +89,43 @@ "workflows": [ { "name": "squad", - "lock_sha256": "930f5583a0931706164b4d48f770faeb0330ad12b29880fa2667f3cd37c2ad95", - "package_lock_sha256": "b3de24942556d64ab1c149762c7d46cf45cb576a86a25b549337978cfba9e22e" + "lock_sha256": "fc8e1c1b9006fddb70edef284b73ab4a5e52e34fb68c1e2cacf24087a9c8c570", + "package_lock_sha256": "791c6749c86c5f4d28912f40a789a0c62a2801c46a1cd230ef0a8098a7403ccf" }, { "name": "squad-implement-worker", - "lock_sha256": "8efb5da6e4042953620a73d4b33687202921c97942b9184d54e75f6bc4315b7d", - "package_lock_sha256": "755365ad065a8d843fbc56ea995e9c9fa7e2220dc89540884104c01a292d2f4a" + "lock_sha256": "8c44adcffa429dba8a7cc30cddde635d37e16e873383b10a75ff12235795ee7b", + "package_lock_sha256": "0ed8eb4a911b3038a066af43803f703cceab207cb53ef7358c0d256e7306eea5" }, { "name": "squad-review", - "lock_sha256": "2b69ede359f9c52602abc19bd11a6e963da056ada14844333ac5ba1e0085893c", - "package_lock_sha256": "decb18305210e737d2b1146a937e0720f5abd7378929e78683876afbe2e01b67" + "lock_sha256": "e1ac795472ca5bd737e4728c2b54668c4570b6cc2a7de262d172f04d8edd7927", + "package_lock_sha256": "01092b66488b090eb236f3182adf945057aaa451ab14b88551452378c378d5e4" }, { "name": "squad-deps-worker", - "lock_sha256": "5b7c7aeb1ce2c0720b20b558b991eb3c5b838cbfeba9aad212ca894efefca21a", - "package_lock_sha256": "c83549cdb00d7f03312f0176a8993f3d60e0325eb4204d2cef33b1fb01974f0b" + "lock_sha256": "4bb64b46474b771d83df98c52c9b3e65e1485b4446e5c1d499cd9f091cff8121", + "package_lock_sha256": "4403fe96b0e9ce5340fa69abe977f5079216fae718005f0f0f16a3cb3779f717" }, { "name": "squad-retro", - "lock_sha256": "1d10cc6eb8bf9387d8d0bb3450dcdaa7d8f60cf5f64d4ac47e14bfaa71feb1fc", - "package_lock_sha256": "4cbe6807df873ad68727a0fbf524cd598f4f4819da116e2b229dbd4b02c0f251" + "lock_sha256": "2f22310121449fe4f2d927639e48c34abd84031628209e7e1e215411e02013b9", + "package_lock_sha256": "2604a9a303e8d85787a7c5a95f69b7cdc265261131de7fc16bd960aa85823d09" }, { "name": "squad-improvement-worker", - "lock_sha256": "16d8991ddd3620708a31d0b787c0e04e0760f7a6a18c62201be8edc804c919d4", - "package_lock_sha256": "f55ea3e4f93fce18873556d491a278fd419c961424d2b8465adb30e73e318363" + "lock_sha256": "67d6f8b5ba5794b0b7b8e1b29c3b558d9e322b7f08e5644c8d286fe50a30776b", + "package_lock_sha256": "0587de56ff07e1f79770716955db6fe2c3c20a3cf38bd79896008c8986ee5974" }, { "name": "squad-bootstrap", - "lock_sha256": "7cc2667bd9611e46764e2f38c675b1d43fe167754158ec4397a46a22d9e9e19e", - "package_lock_sha256": "1899588770c9a5e9fbc2edfe385ab8618d35c4a0a111e5018fe34af59e5f9ca5" + "lock_sha256": "9424095fee656651f943ad6480cb16a1d83581f81cb04b806fd11ba308b9ec75", + "package_lock_sha256": "c9a72a3757351109b59b769b0b1f9481f269eb63c919bcc10610cb8d76d93274" }, { "name": "squad-command-router", - "lock_sha256": "04c2c877eb9e5e594b5b1d804e5fdfd19c9efdf0d0a76fa214cdbb614197fa6c", - "package_lock_sha256": "9a6d72873cc8d39ca2de9854c1e4eaac88818fde755a398a8c7ca59b9e3a88c0" + "lock_sha256": "8e1658e527380399105c6add9868b3696005ab9a2281708d7ce822fcaf964ee1", + "package_lock_sha256": "ae8f8a656e4e3451e37259c5ae847e05a23b45c3f23f5dc0f105e1127f6b465c" } ] } @@ -169,7 +169,7 @@ "package_destination": ".github/workflows/shared/squad-install-verifier.mjs", "destination": ".github/workflows/shared/squad-install-verifier.mjs", "ownership": "manifest", - "sha256": "d12cf4de38e716dd6d0bbb03fda22675477a4559573d3a8601b8f5b2f88f544e" + "sha256": "eb6709d221e55f87d3fdb522a4f2bc4c21cc8bf96cb38908f93e2d5817f96735" }, { "path": "shared/squad-command-contract.mjs", @@ -241,7 +241,7 @@ "package_destination": ".github/aw/squad/runtime/shared/squad.md", "destination": ".github/workflows/shared/squad.md", "ownership": "manifest", - "sha256": "bfe0417921cd23551afb3511aac03e3414d73412a842c9983cf39015700a8bf3" + "sha256": "5969bc480ab559346f4a379bb82d54288628c9ecdbcb11b6bae573ff45db32b3" }, { "path": "shared/squad-planning-ontology.md", @@ -305,7 +305,7 @@ "name": "gh-aw-enlistment", "source": "workflows/skills/gh-aw-enlistment/SKILL.md", "destination": ".github/skills/gh-aw-enlistment/SKILL.md", - "sha256": "b982dfa4572c9b16ffd3ddf3ed3102562dce5098b9ec14d27b76ad0ffc964800" + "sha256": "2a6b70ae2bbe036eed1ace0bafa746653b8cc1cf1606c8b14a2ad97c69c3a82a" } ], "bootstrap": { From 7545e6e9964c0435d90777c51c7607ed12d6846a Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Sun, 8 Nov 2026 22:12:50 +0200 Subject: [PATCH 2/6] fix(gh-aw): guard legacy-identity upgrade cleanup and migration warnings Address two Copilot review findings on #2410. Stop the upgrade before any cleanup when the forced reinstall fails. The upgrade block in the gh-aw-enlistment skill, its four mirrors and the gh-aw guide deleted the legacy .github/aw/packages/bradygaster-squad-workflows-3632054824e8.json ownership record even when `gh aw add --force` failed, leaving the previous installation without its verification metadata. The block now stops with a STOP message before removing anything. The verifier prints its recovery and legacy-move commands as one && chain, so a failed reinstall stops them too, and its staged-tree message says to delete the legacy record only after the reinstall succeeds. Allow the four squad-init replacement warnings only during the legacy-identity migration. gh-aw v0.91.5 compares safe updates against the locks committed at HEAD. While legacy locks are committed, every strict compile, even after --approve, reports the bradygaster/squad -> github/squad squad-init owner change once each in squad.md, squad-implement-worker.md, squad-deps-worker.md and squad-retro.md (8 succeeded, 6 warnings), so the two-warning gate stopped a valid migration before it could be committed. The skill and the guide now allowlist exactly those four diagnostics and their exact change list during that migration only. Any other warning, change, duplicate or summary still stops the flow. Tests run each upgrade block and the verifier's printed commands with a failing and a succeeding stubbed `gh aw add`, and apply each document's warning gate to real gh-aw v0.91.5 output from a repository with committed legacy locks: with --approve, without it, and after the upgrade commit. Mutation tests reject a widened migration allowlist. Regenerate the package manifest and bootstrap verifier digest, and extend the changeset. Refs #2403 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../repo-move-github-squad-gh-aw-identity.md | 2 +- .../skills/gh-aw-enlistment/SKILL.md | 72 +++- docs/src/content/docs/guide/gh-aw.md | 66 +++- .../skills/gh-aw-enlistment/SKILL.md | 72 +++- .../skills/gh-aw-enlistment/SKILL.md | 72 +++- templates/skills/gh-aw-enlistment/SKILL.md | 72 +++- test/gh-aw-enlistment-skill.test.ts | 356 +++++++++++++++++- test/gh-aw-install-staging.test.ts | 82 +++- workflows/package/squad-bootstrap.md | 2 +- workflows/shared/squad-install-verifier.mjs | 29 +- workflows/skills/gh-aw-enlistment/SKILL.md | 72 +++- workflows/squad-bootstrap.md | 2 +- workflows/squad-workflows.manifest.json | 14 +- 13 files changed, 796 insertions(+), 117 deletions(-) diff --git a/.changeset/repo-move-github-squad-gh-aw-identity.md b/.changeset/repo-move-github-squad-gh-aw-identity.md index 11d9241a4..c84550895 100644 --- a/.changeset/repo-move-github-squad-gh-aw-identity.md +++ b/.changeset/repo-move-github-squad-gh-aw-identity.md @@ -3,4 +3,4 @@ '@bradygaster/squad-sdk': patch --- -Update the shipped gh-aw-enlistment skill for the repository's new home, `github/squad`. It now installs the gh-aw package as `github/squad/workflows`, links to the docs guide's new address, and allowlists the `github/squad/.github/actions/squad-init` action. Before installing or upgrading, it checks that the chosen Squad revision declares `github/squad/workflows` in its package manifest, and stops otherwise. It also explains how to move an installation off the legacy `bradygaster/squad/workflows` identity, which the install verifier still accepts. +Update the shipped gh-aw-enlistment skill for the repository's new home, `github/squad`. It now installs the gh-aw package as `github/squad/workflows`, links to the docs guide's new address, and allowlists the `github/squad/.github/actions/squad-init` action. Before installing or upgrading, it checks that the chosen Squad revision declares `github/squad/workflows` in its package manifest, and stops otherwise. It also explains how to move an installation off the legacy `bradygaster/squad/workflows` identity, which the install verifier still accepts. That upgrade stops before removing anything if the forced reinstall fails, and until it is committed, its strict compile gate accepts only the four expected squad-init owner-replacement warnings. diff --git a/.squad-templates/skills/gh-aw-enlistment/SKILL.md b/.squad-templates/skills/gh-aw-enlistment/SKILL.md index 91043f57a..9839df422 100644 --- a/.squad-templates/skills/gh-aw-enlistment/SKILL.md +++ b/.squad-templates/skills/gh-aw-enlistment/SKILL.md @@ -260,18 +260,21 @@ installation to a revision that declares `github/squad/workflows` with the block below. gh-aw keeps the superseded `.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json` after the forced reinstall, so the block deletes exactly that file before verifying; the -verifier fails closed while both ownership records exist. That first upgrade +verifier fails closed while both ownership records exist. If the forced +reinstall fails, the block stops before it removes anything. That first upgrade also makes `gh aw add` report one safe-update change: action `github/squad/.github/actions/squad-init` added and `bradygaster/squad/.github/actions/squad-init` removed. It is the same squad-init action under the repository's new owner; list it in the PR's -security review note. **STOP** on any other new secret or action. Until an +security review note. **STOP** on any other new secret or action. Until the +upgrade is committed, every strict compile repeats that change as four +warnings, which step 5 allows only for this migration. Until an installation moves, `--verify-install` still passes and prints a notice naming the legacy identity. To stay on a revision that declares -`bradygaster/squad/workflows`, replace the identity check and the `gh aw add` -line below with `gh aw add "bradygaster/squad/workflows@${SQUAD_SHA}" --force`, -and skip the second `rm -f`: that ownership record is the only one such an -installation has. +`bradygaster/squad/workflows`, drop the identity check below, keep the +`gh aw add` line's STOP guard but replace its command with +`gh aw add "bradygaster/squad/workflows@${SQUAD_SHA}" --force`, and skip the +second `rm -f`: that ownership record is the only one such an installation has. ```bash : "${SQUAD_SHA:?STOP: set SQUAD_SHA to an explicit, maintainer-approved 40-character Squad commit SHA before upgrading.}" @@ -292,7 +295,10 @@ test "${squad_package}" = "github/squad/workflows" || { fi exit 1 } -gh aw add "github/squad/workflows@${SQUAD_SHA}" --force +gh aw add "github/squad/workflows@${SQUAD_SHA}" --force || { + echo "STOP: gh aw add --force failed, so this block removed nothing. Fix the error above, then rerun the block." >&2 + exit 1 +} rm -f .github/skills/agentic-workflows/SKILL.md rm -f .github/aw/packages/bradygaster-squad-workflows-3632054824e8.json node .github/workflows/shared/squad-install-verifier.mjs --materialize-runtime @@ -303,7 +309,7 @@ node .github/workflows/shared/squad-install-verifier.mjs \ --strict-compile ``` -Apply step 5's two-warning check to the compile output above, then stage and verify as in step 7. +Apply step 5's warning check, including its legacy-identity migration allowlist, to the compile output above, then stage and verify as in step 7. Report/proposal-only is the default. Ordinary fixes require the explicit `"squadRetroAutoImplement": "allow"` setting in `.squad/config.json`; @@ -390,11 +396,48 @@ least-privilege jobs, advisory verdict, and independent human approval. `pull_request_target` is not generally safe; this narrow exception neither weakens those controls nor authorizes PR-head execution. +**Legacy-identity migration only.** While step 3's upgrade block moves an +installation whose locks committed at `HEAD` still use +`bradygaster/squad/.github/actions/squad-init`, gh-aw v0.91.5 compares safe +updates against those committed locks, and `--approve` cannot clear that +comparison before the upgrade is committed. During that migration, also +require exactly these four warnings, one occurrence of each exact diagnostic +header: + + +```text +.github/workflows/squad.md: warning: safe update mode detected unapproved changes +.github/workflows/squad-implement-worker.md: warning: safe update mode detected unapproved changes +.github/workflows/squad-deps-worker.md: warning: safe update mode detected unapproved changes +.github/workflows/squad-retro.md: warning: safe update mode detected unapproved changes +``` + + +Each of these four warnings must report exactly this squad-init owner +replacement, with no other secret, action, redirect, or change: + + +```text +New unapproved action(s): + - github/squad/.github/actions/squad-init +Previously-approved action(s) removed: + - bradygaster/squad/.github/actions/squad-init +``` + + +The remediation text under each warning and the closing `SECURITY REVIEW +REQUIRED` prompts belong to these four warnings; they are not additional +warnings. The summary is then `Compiled 8 workflows: 8 succeeded, 6 warnings`. +Name the replacement in the PR's security review note. The four warnings no +longer appear once the upgrade is committed. + **STOP** on any error, or on **any additional warning** beyond these two exact -documented diagnostics. Also STOP if either warning is missing, duplicated, -changed, or attributed to another path, if the summary is not -`Compiled 8 workflows: 8 succeeded, 2 warnings`, or if any required control is -absent. Do not suppress warnings or use `--approve` to bypass this gate. +documented diagnostics and, during that migration only, the four exact +squad-init replacement warnings. Also STOP if an allowed warning is missing, +duplicated, changed, or attributed to another path, if the summary is not +`Compiled 8 workflows: 8 succeeded, 2 warnings` (`6 warnings` during that +migration), or if any required control is absent. Do not suppress warnings or +use `--approve` to bypass this gate. ### 6. Require the verifier to prove the complete consumer contract @@ -672,8 +715,9 @@ gh pr merge --squash # auto-merge before human review. NEVER. - ❌ **Treating `--approve` as the final compile.** Always finish with a plain `gh aw compile --strict` (no `--approve`). - ❌ **Tolerating extra warnings.** Only the documented `squad-review.md` - `pull_request_target` advisory and `squad.md` bot-trigger warning are allowed; - every other warning or error halts the run. + `pull_request_target` advisory and `squad.md` bot-trigger warning are allowed, + plus step 5's four squad-init replacement warnings during the legacy-identity + migration only; every other warning or error halts the run. - ❌ **Committing diagnostics.** Never commit `.github/aw/logs/` output; add the log `.gitignore` if missing. - ❌ **Clobbering existing workflows.** The install is additive; preserve unrelated diff --git a/docs/src/content/docs/guide/gh-aw.md b/docs/src/content/docs/guide/gh-aw.md index d5b4d4107..bccbd33c6 100644 --- a/docs/src/content/docs/guide/gh-aw.md +++ b/docs/src/content/docs/guide/gh-aw.md @@ -736,11 +736,48 @@ least-privilege jobs, advisory verdict, and independent human approval. `pull_request_target` is not generally safe; this narrow exception neither weakens those controls nor authorizes PR-head execution. +**Legacy-identity migration only.** While the [Upgrading](#upgrading) steps move +an installation whose locks committed at `HEAD` still use +`bradygaster/squad/.github/actions/squad-init`, gh-aw v0.91.5 compares safe +updates against those committed locks, and `--approve` cannot clear that +comparison before the upgrade is committed. During that migration, also +require exactly these four warnings, one occurrence of each exact diagnostic +header: + + +```text +.github/workflows/squad.md: warning: safe update mode detected unapproved changes +.github/workflows/squad-implement-worker.md: warning: safe update mode detected unapproved changes +.github/workflows/squad-deps-worker.md: warning: safe update mode detected unapproved changes +.github/workflows/squad-retro.md: warning: safe update mode detected unapproved changes +``` + + +Each of these four warnings must report exactly this squad-init owner +replacement, with no other secret, action, redirect, or change: + + +```text +New unapproved action(s): + - github/squad/.github/actions/squad-init +Previously-approved action(s) removed: + - bradygaster/squad/.github/actions/squad-init +``` + + +The remediation text under each warning and the closing `SECURITY REVIEW +REQUIRED` prompts belong to these four warnings; they are not additional +warnings. The summary is then `Compiled 8 workflows: 8 succeeded, 6 warnings`. +Name the replacement in the pull request's security review note. The four +warnings no longer appear once the upgrade is committed. + **STOP** on any error, or on **any additional warning** beyond these two exact -documented diagnostics. Also STOP if either warning is missing, duplicated, -changed, or attributed to another path, if the summary is not -`Compiled 8 workflows: 8 succeeded, 2 warnings`, or if any required control is -absent. Do not suppress warnings or use `--approve` to bypass this gate. +documented diagnostics and, during that migration only, the four exact +squad-init replacement warnings. Also STOP if an allowed warning is missing, +duplicated, changed, or attributed to another path, if the summary is not +`Compiled 8 workflows: 8 succeeded, 2 warnings` (`6 warnings` during that +migration), or if any required control is absent. Do not suppress warnings or +use `--approve` to bypass this gate. Verify the complete source/lock surface. Prefer the manifest-backed verifier over a hand-maintained file list, since it checks every one of the eight @@ -2565,7 +2602,10 @@ test "${squad_package}" = "github/squad/workflows" || { exit 1 } -gh aw add "github/squad/workflows@${SQUAD_SHA}" --force +gh aw add "github/squad/workflows@${SQUAD_SHA}" --force || { + echo "STOP: gh aw add --force failed, so this block removed nothing. Fix the error above, then rerun the block." >&2 + exit 1 +} rm -f .github/skills/agentic-workflows/SKILL.md rm -f .github/aw/packages/bradygaster-squad-workflows-3632054824e8.json node .github/workflows/shared/squad-install-verifier.mjs --materialize-runtime @@ -2578,7 +2618,8 @@ node .github/workflows/shared/squad-install-verifier.mjs \ `--force` overwrites package-owned files. Save local source customizations first, then reapply them before the final compile. Never customize generated -`.lock.yml` files. The verifier rejects a mixed revision, stale digest, missing +`.lock.yml` files. If the forced reinstall fails, the block stops before it +removes anything. The verifier rejects a mixed revision, stale digest, missing ownership record, missing source/lock pair, or absent runtime resource and prints the exact safe recovery commands. @@ -2588,11 +2629,11 @@ Revisions whose `workflows/squad-workflows.manifest.json` declares committed after the repository moved to `github/squad`, declare and verify only `bradygaster/squad/workflows`, and installations made from them carry it. The identity check above stops unless the target revision declares -`github/squad/workflows`. To stay on an earlier revision instead, replace the -identity check and the `gh aw add` line with -`gh aw add "bradygaster/squad/workflows@${SQUAD_SHA}" --force`, and skip the -second `rm -f`: that ownership record is the only one such an installation has. -Otherwise ask the Squad maintainers for a newer revision. +`github/squad/workflows`. To stay on an earlier revision instead, drop the +identity check, keep the `gh aw add` line's STOP guard but replace its +command with `gh aw add "bradygaster/squad/workflows@${SQUAD_SHA}" --force`, +and skip the second `rm -f`: that ownership record is the only one such an +installation has. Otherwise ask the Squad maintainers for a newer revision. Upgrading a legacy installation to a revision that declares `github/squad/workflows` installs `github/squad/workflows`. gh-aw keeps the @@ -2606,6 +2647,9 @@ safe-update change: action `bradygaster/squad/.github/actions/squad-init` removed. It is the same squad-init action under the repository's new owner. List it in the pull request's security review note, and stop on any other new secret or action. +Until the upgrade is committed, every strict compile repeats that change as +four warnings; accept them only through the legacy-identity migration +allowlist in [Strictly compile the installed workflows](#strictly-compile-the-installed-workflows). If your organization allows only selected actions, allow `github/squad/.github/actions/squad-init` (or actions created by GitHub) before merging the upgrade. Until an installation moves, `--verify-install` diff --git a/packages/squad-cli/templates/skills/gh-aw-enlistment/SKILL.md b/packages/squad-cli/templates/skills/gh-aw-enlistment/SKILL.md index 91043f57a..9839df422 100644 --- a/packages/squad-cli/templates/skills/gh-aw-enlistment/SKILL.md +++ b/packages/squad-cli/templates/skills/gh-aw-enlistment/SKILL.md @@ -260,18 +260,21 @@ installation to a revision that declares `github/squad/workflows` with the block below. gh-aw keeps the superseded `.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json` after the forced reinstall, so the block deletes exactly that file before verifying; the -verifier fails closed while both ownership records exist. That first upgrade +verifier fails closed while both ownership records exist. If the forced +reinstall fails, the block stops before it removes anything. That first upgrade also makes `gh aw add` report one safe-update change: action `github/squad/.github/actions/squad-init` added and `bradygaster/squad/.github/actions/squad-init` removed. It is the same squad-init action under the repository's new owner; list it in the PR's -security review note. **STOP** on any other new secret or action. Until an +security review note. **STOP** on any other new secret or action. Until the +upgrade is committed, every strict compile repeats that change as four +warnings, which step 5 allows only for this migration. Until an installation moves, `--verify-install` still passes and prints a notice naming the legacy identity. To stay on a revision that declares -`bradygaster/squad/workflows`, replace the identity check and the `gh aw add` -line below with `gh aw add "bradygaster/squad/workflows@${SQUAD_SHA}" --force`, -and skip the second `rm -f`: that ownership record is the only one such an -installation has. +`bradygaster/squad/workflows`, drop the identity check below, keep the +`gh aw add` line's STOP guard but replace its command with +`gh aw add "bradygaster/squad/workflows@${SQUAD_SHA}" --force`, and skip the +second `rm -f`: that ownership record is the only one such an installation has. ```bash : "${SQUAD_SHA:?STOP: set SQUAD_SHA to an explicit, maintainer-approved 40-character Squad commit SHA before upgrading.}" @@ -292,7 +295,10 @@ test "${squad_package}" = "github/squad/workflows" || { fi exit 1 } -gh aw add "github/squad/workflows@${SQUAD_SHA}" --force +gh aw add "github/squad/workflows@${SQUAD_SHA}" --force || { + echo "STOP: gh aw add --force failed, so this block removed nothing. Fix the error above, then rerun the block." >&2 + exit 1 +} rm -f .github/skills/agentic-workflows/SKILL.md rm -f .github/aw/packages/bradygaster-squad-workflows-3632054824e8.json node .github/workflows/shared/squad-install-verifier.mjs --materialize-runtime @@ -303,7 +309,7 @@ node .github/workflows/shared/squad-install-verifier.mjs \ --strict-compile ``` -Apply step 5's two-warning check to the compile output above, then stage and verify as in step 7. +Apply step 5's warning check, including its legacy-identity migration allowlist, to the compile output above, then stage and verify as in step 7. Report/proposal-only is the default. Ordinary fixes require the explicit `"squadRetroAutoImplement": "allow"` setting in `.squad/config.json`; @@ -390,11 +396,48 @@ least-privilege jobs, advisory verdict, and independent human approval. `pull_request_target` is not generally safe; this narrow exception neither weakens those controls nor authorizes PR-head execution. +**Legacy-identity migration only.** While step 3's upgrade block moves an +installation whose locks committed at `HEAD` still use +`bradygaster/squad/.github/actions/squad-init`, gh-aw v0.91.5 compares safe +updates against those committed locks, and `--approve` cannot clear that +comparison before the upgrade is committed. During that migration, also +require exactly these four warnings, one occurrence of each exact diagnostic +header: + + +```text +.github/workflows/squad.md: warning: safe update mode detected unapproved changes +.github/workflows/squad-implement-worker.md: warning: safe update mode detected unapproved changes +.github/workflows/squad-deps-worker.md: warning: safe update mode detected unapproved changes +.github/workflows/squad-retro.md: warning: safe update mode detected unapproved changes +``` + + +Each of these four warnings must report exactly this squad-init owner +replacement, with no other secret, action, redirect, or change: + + +```text +New unapproved action(s): + - github/squad/.github/actions/squad-init +Previously-approved action(s) removed: + - bradygaster/squad/.github/actions/squad-init +``` + + +The remediation text under each warning and the closing `SECURITY REVIEW +REQUIRED` prompts belong to these four warnings; they are not additional +warnings. The summary is then `Compiled 8 workflows: 8 succeeded, 6 warnings`. +Name the replacement in the PR's security review note. The four warnings no +longer appear once the upgrade is committed. + **STOP** on any error, or on **any additional warning** beyond these two exact -documented diagnostics. Also STOP if either warning is missing, duplicated, -changed, or attributed to another path, if the summary is not -`Compiled 8 workflows: 8 succeeded, 2 warnings`, or if any required control is -absent. Do not suppress warnings or use `--approve` to bypass this gate. +documented diagnostics and, during that migration only, the four exact +squad-init replacement warnings. Also STOP if an allowed warning is missing, +duplicated, changed, or attributed to another path, if the summary is not +`Compiled 8 workflows: 8 succeeded, 2 warnings` (`6 warnings` during that +migration), or if any required control is absent. Do not suppress warnings or +use `--approve` to bypass this gate. ### 6. Require the verifier to prove the complete consumer contract @@ -672,8 +715,9 @@ gh pr merge --squash # auto-merge before human review. NEVER. - ❌ **Treating `--approve` as the final compile.** Always finish with a plain `gh aw compile --strict` (no `--approve`). - ❌ **Tolerating extra warnings.** Only the documented `squad-review.md` - `pull_request_target` advisory and `squad.md` bot-trigger warning are allowed; - every other warning or error halts the run. + `pull_request_target` advisory and `squad.md` bot-trigger warning are allowed, + plus step 5's four squad-init replacement warnings during the legacy-identity + migration only; every other warning or error halts the run. - ❌ **Committing diagnostics.** Never commit `.github/aw/logs/` output; add the log `.gitignore` if missing. - ❌ **Clobbering existing workflows.** The install is additive; preserve unrelated diff --git a/packages/squad-sdk/templates/skills/gh-aw-enlistment/SKILL.md b/packages/squad-sdk/templates/skills/gh-aw-enlistment/SKILL.md index 91043f57a..9839df422 100644 --- a/packages/squad-sdk/templates/skills/gh-aw-enlistment/SKILL.md +++ b/packages/squad-sdk/templates/skills/gh-aw-enlistment/SKILL.md @@ -260,18 +260,21 @@ installation to a revision that declares `github/squad/workflows` with the block below. gh-aw keeps the superseded `.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json` after the forced reinstall, so the block deletes exactly that file before verifying; the -verifier fails closed while both ownership records exist. That first upgrade +verifier fails closed while both ownership records exist. If the forced +reinstall fails, the block stops before it removes anything. That first upgrade also makes `gh aw add` report one safe-update change: action `github/squad/.github/actions/squad-init` added and `bradygaster/squad/.github/actions/squad-init` removed. It is the same squad-init action under the repository's new owner; list it in the PR's -security review note. **STOP** on any other new secret or action. Until an +security review note. **STOP** on any other new secret or action. Until the +upgrade is committed, every strict compile repeats that change as four +warnings, which step 5 allows only for this migration. Until an installation moves, `--verify-install` still passes and prints a notice naming the legacy identity. To stay on a revision that declares -`bradygaster/squad/workflows`, replace the identity check and the `gh aw add` -line below with `gh aw add "bradygaster/squad/workflows@${SQUAD_SHA}" --force`, -and skip the second `rm -f`: that ownership record is the only one such an -installation has. +`bradygaster/squad/workflows`, drop the identity check below, keep the +`gh aw add` line's STOP guard but replace its command with +`gh aw add "bradygaster/squad/workflows@${SQUAD_SHA}" --force`, and skip the +second `rm -f`: that ownership record is the only one such an installation has. ```bash : "${SQUAD_SHA:?STOP: set SQUAD_SHA to an explicit, maintainer-approved 40-character Squad commit SHA before upgrading.}" @@ -292,7 +295,10 @@ test "${squad_package}" = "github/squad/workflows" || { fi exit 1 } -gh aw add "github/squad/workflows@${SQUAD_SHA}" --force +gh aw add "github/squad/workflows@${SQUAD_SHA}" --force || { + echo "STOP: gh aw add --force failed, so this block removed nothing. Fix the error above, then rerun the block." >&2 + exit 1 +} rm -f .github/skills/agentic-workflows/SKILL.md rm -f .github/aw/packages/bradygaster-squad-workflows-3632054824e8.json node .github/workflows/shared/squad-install-verifier.mjs --materialize-runtime @@ -303,7 +309,7 @@ node .github/workflows/shared/squad-install-verifier.mjs \ --strict-compile ``` -Apply step 5's two-warning check to the compile output above, then stage and verify as in step 7. +Apply step 5's warning check, including its legacy-identity migration allowlist, to the compile output above, then stage and verify as in step 7. Report/proposal-only is the default. Ordinary fixes require the explicit `"squadRetroAutoImplement": "allow"` setting in `.squad/config.json`; @@ -390,11 +396,48 @@ least-privilege jobs, advisory verdict, and independent human approval. `pull_request_target` is not generally safe; this narrow exception neither weakens those controls nor authorizes PR-head execution. +**Legacy-identity migration only.** While step 3's upgrade block moves an +installation whose locks committed at `HEAD` still use +`bradygaster/squad/.github/actions/squad-init`, gh-aw v0.91.5 compares safe +updates against those committed locks, and `--approve` cannot clear that +comparison before the upgrade is committed. During that migration, also +require exactly these four warnings, one occurrence of each exact diagnostic +header: + + +```text +.github/workflows/squad.md: warning: safe update mode detected unapproved changes +.github/workflows/squad-implement-worker.md: warning: safe update mode detected unapproved changes +.github/workflows/squad-deps-worker.md: warning: safe update mode detected unapproved changes +.github/workflows/squad-retro.md: warning: safe update mode detected unapproved changes +``` + + +Each of these four warnings must report exactly this squad-init owner +replacement, with no other secret, action, redirect, or change: + + +```text +New unapproved action(s): + - github/squad/.github/actions/squad-init +Previously-approved action(s) removed: + - bradygaster/squad/.github/actions/squad-init +``` + + +The remediation text under each warning and the closing `SECURITY REVIEW +REQUIRED` prompts belong to these four warnings; they are not additional +warnings. The summary is then `Compiled 8 workflows: 8 succeeded, 6 warnings`. +Name the replacement in the PR's security review note. The four warnings no +longer appear once the upgrade is committed. + **STOP** on any error, or on **any additional warning** beyond these two exact -documented diagnostics. Also STOP if either warning is missing, duplicated, -changed, or attributed to another path, if the summary is not -`Compiled 8 workflows: 8 succeeded, 2 warnings`, or if any required control is -absent. Do not suppress warnings or use `--approve` to bypass this gate. +documented diagnostics and, during that migration only, the four exact +squad-init replacement warnings. Also STOP if an allowed warning is missing, +duplicated, changed, or attributed to another path, if the summary is not +`Compiled 8 workflows: 8 succeeded, 2 warnings` (`6 warnings` during that +migration), or if any required control is absent. Do not suppress warnings or +use `--approve` to bypass this gate. ### 6. Require the verifier to prove the complete consumer contract @@ -672,8 +715,9 @@ gh pr merge --squash # auto-merge before human review. NEVER. - ❌ **Treating `--approve` as the final compile.** Always finish with a plain `gh aw compile --strict` (no `--approve`). - ❌ **Tolerating extra warnings.** Only the documented `squad-review.md` - `pull_request_target` advisory and `squad.md` bot-trigger warning are allowed; - every other warning or error halts the run. + `pull_request_target` advisory and `squad.md` bot-trigger warning are allowed, + plus step 5's four squad-init replacement warnings during the legacy-identity + migration only; every other warning or error halts the run. - ❌ **Committing diagnostics.** Never commit `.github/aw/logs/` output; add the log `.gitignore` if missing. - ❌ **Clobbering existing workflows.** The install is additive; preserve unrelated diff --git a/templates/skills/gh-aw-enlistment/SKILL.md b/templates/skills/gh-aw-enlistment/SKILL.md index 91043f57a..9839df422 100644 --- a/templates/skills/gh-aw-enlistment/SKILL.md +++ b/templates/skills/gh-aw-enlistment/SKILL.md @@ -260,18 +260,21 @@ installation to a revision that declares `github/squad/workflows` with the block below. gh-aw keeps the superseded `.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json` after the forced reinstall, so the block deletes exactly that file before verifying; the -verifier fails closed while both ownership records exist. That first upgrade +verifier fails closed while both ownership records exist. If the forced +reinstall fails, the block stops before it removes anything. That first upgrade also makes `gh aw add` report one safe-update change: action `github/squad/.github/actions/squad-init` added and `bradygaster/squad/.github/actions/squad-init` removed. It is the same squad-init action under the repository's new owner; list it in the PR's -security review note. **STOP** on any other new secret or action. Until an +security review note. **STOP** on any other new secret or action. Until the +upgrade is committed, every strict compile repeats that change as four +warnings, which step 5 allows only for this migration. Until an installation moves, `--verify-install` still passes and prints a notice naming the legacy identity. To stay on a revision that declares -`bradygaster/squad/workflows`, replace the identity check and the `gh aw add` -line below with `gh aw add "bradygaster/squad/workflows@${SQUAD_SHA}" --force`, -and skip the second `rm -f`: that ownership record is the only one such an -installation has. +`bradygaster/squad/workflows`, drop the identity check below, keep the +`gh aw add` line's STOP guard but replace its command with +`gh aw add "bradygaster/squad/workflows@${SQUAD_SHA}" --force`, and skip the +second `rm -f`: that ownership record is the only one such an installation has. ```bash : "${SQUAD_SHA:?STOP: set SQUAD_SHA to an explicit, maintainer-approved 40-character Squad commit SHA before upgrading.}" @@ -292,7 +295,10 @@ test "${squad_package}" = "github/squad/workflows" || { fi exit 1 } -gh aw add "github/squad/workflows@${SQUAD_SHA}" --force +gh aw add "github/squad/workflows@${SQUAD_SHA}" --force || { + echo "STOP: gh aw add --force failed, so this block removed nothing. Fix the error above, then rerun the block." >&2 + exit 1 +} rm -f .github/skills/agentic-workflows/SKILL.md rm -f .github/aw/packages/bradygaster-squad-workflows-3632054824e8.json node .github/workflows/shared/squad-install-verifier.mjs --materialize-runtime @@ -303,7 +309,7 @@ node .github/workflows/shared/squad-install-verifier.mjs \ --strict-compile ``` -Apply step 5's two-warning check to the compile output above, then stage and verify as in step 7. +Apply step 5's warning check, including its legacy-identity migration allowlist, to the compile output above, then stage and verify as in step 7. Report/proposal-only is the default. Ordinary fixes require the explicit `"squadRetroAutoImplement": "allow"` setting in `.squad/config.json`; @@ -390,11 +396,48 @@ least-privilege jobs, advisory verdict, and independent human approval. `pull_request_target` is not generally safe; this narrow exception neither weakens those controls nor authorizes PR-head execution. +**Legacy-identity migration only.** While step 3's upgrade block moves an +installation whose locks committed at `HEAD` still use +`bradygaster/squad/.github/actions/squad-init`, gh-aw v0.91.5 compares safe +updates against those committed locks, and `--approve` cannot clear that +comparison before the upgrade is committed. During that migration, also +require exactly these four warnings, one occurrence of each exact diagnostic +header: + + +```text +.github/workflows/squad.md: warning: safe update mode detected unapproved changes +.github/workflows/squad-implement-worker.md: warning: safe update mode detected unapproved changes +.github/workflows/squad-deps-worker.md: warning: safe update mode detected unapproved changes +.github/workflows/squad-retro.md: warning: safe update mode detected unapproved changes +``` + + +Each of these four warnings must report exactly this squad-init owner +replacement, with no other secret, action, redirect, or change: + + +```text +New unapproved action(s): + - github/squad/.github/actions/squad-init +Previously-approved action(s) removed: + - bradygaster/squad/.github/actions/squad-init +``` + + +The remediation text under each warning and the closing `SECURITY REVIEW +REQUIRED` prompts belong to these four warnings; they are not additional +warnings. The summary is then `Compiled 8 workflows: 8 succeeded, 6 warnings`. +Name the replacement in the PR's security review note. The four warnings no +longer appear once the upgrade is committed. + **STOP** on any error, or on **any additional warning** beyond these two exact -documented diagnostics. Also STOP if either warning is missing, duplicated, -changed, or attributed to another path, if the summary is not -`Compiled 8 workflows: 8 succeeded, 2 warnings`, or if any required control is -absent. Do not suppress warnings or use `--approve` to bypass this gate. +documented diagnostics and, during that migration only, the four exact +squad-init replacement warnings. Also STOP if an allowed warning is missing, +duplicated, changed, or attributed to another path, if the summary is not +`Compiled 8 workflows: 8 succeeded, 2 warnings` (`6 warnings` during that +migration), or if any required control is absent. Do not suppress warnings or +use `--approve` to bypass this gate. ### 6. Require the verifier to prove the complete consumer contract @@ -672,8 +715,9 @@ gh pr merge --squash # auto-merge before human review. NEVER. - ❌ **Treating `--approve` as the final compile.** Always finish with a plain `gh aw compile --strict` (no `--approve`). - ❌ **Tolerating extra warnings.** Only the documented `squad-review.md` - `pull_request_target` advisory and `squad.md` bot-trigger warning are allowed; - every other warning or error halts the run. + `pull_request_target` advisory and `squad.md` bot-trigger warning are allowed, + plus step 5's four squad-init replacement warnings during the legacy-identity + migration only; every other warning or error halts the run. - ❌ **Committing diagnostics.** Never commit `.github/aw/logs/` output; add the log `.gitignore` if missing. - ❌ **Clobbering existing workflows.** The install is additive; preserve unrelated diff --git a/test/gh-aw-enlistment-skill.test.ts b/test/gh-aw-enlistment-skill.test.ts index 79721cb82..323cf0e23 100644 --- a/test/gh-aw-enlistment-skill.test.ts +++ b/test/gh-aw-enlistment-skill.test.ts @@ -11,11 +11,15 @@ * compile, never-auto-merge, read-only token, explicit staging). * 4. The canonical source and all four mirrors are byte-for-byte identical * (the sync invariant every canonical skill upholds). + * 5. Every upgrade block stops before any cleanup when the forced reinstall fails. + * 6. The strict-compile warning gate accepts the four squad-init replacement + * warnings that the real compiler emits while legacy locks are committed, + * and nothing broader. */ -import { describe, it, expect } from 'vitest'; -import { readFileSync, existsSync } from 'node:fs'; -import { spawnSync } from 'node:child_process'; +import { describe, it, expect, beforeAll } from 'vitest'; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { execFileSync, spawnSync } from 'node:child_process'; import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { parseSkillFile } from '@bradygaster/squad-sdk/skills'; @@ -25,6 +29,8 @@ import { OWNERSHIP_DESTINATION, PACKAGE_NAME, UNOWNED_MUTABLE_ROUTER_SKILL, + WORKFLOW_TUPLES, + compileWithPinnedActions, } from '../workflows/shared/squad-install-verifier.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -47,6 +53,7 @@ const INSTALL_COMMAND = `gh aw add "${PACKAGE_NAME}@\${SQUAD_SHA}"`; const [LEGACY_PACKAGE_NAME] = LEGACY_PACKAGE_NAMES; const [LEGACY_OWNERSHIP] = LEGACY_OWNERSHIP_DESTINATIONS; const SQUAD_INIT_ACTION = `${PACKAGE_NAME.replace(/\/workflows$/, '')}/.github/actions/squad-init`; +const LEGACY_SQUAD_INIT_ACTION = `${LEGACY_PACKAGE_NAME.replace(/\/workflows$/, '')}/.github/actions/squad-init`; const DELETION_FILTER = [ ` grep -vxF -e '${UNOWNED_MUTABLE_ROUTER_SKILL}' \\`, ` -e '${LEGACY_OWNERSHIP}' || true`, @@ -198,13 +205,163 @@ function assertCompileWarningContract(content: string): void { expect(regions[0][1].split('\n')).toEqual(COMPILE_WARNINGS); const flat = content.replace(/\s+/g, ' '); expect(flat).toContain('require exactly two warnings, one occurrence of each exact diagnostic header'); - expect(flat).toContain('**STOP** on any error, or on **any additional warning** beyond these two exact documented diagnostics.'); - expect(flat).toContain('Also STOP if either warning is missing, duplicated, changed, or attributed to another path'); + expect(flat).toContain( + '**STOP** on any error, or on **any additional warning** beyond these two exact documented diagnostics and, ' + + 'during that migration only, the four exact squad-init replacement warnings.', + ); + expect(flat).toContain('Also STOP if an allowed warning is missing, duplicated, changed, or attributed to another path'); expect(flat).toContain('Compiled 8 workflows: 8 succeeded, 2 warnings'); expect(flat).toContain('or if any required control is absent.'); expect(flat).toContain('Do not suppress warnings or use `--approve` to bypass this gate.'); } +const SAFE_UPDATE_WARNING = 'warning: safe update mode detected unapproved changes'; +// Each package workflow that runs the squad-init action reports the owner change. +const SQUAD_INIT_WORKFLOWS: string[] = WORKFLOW_TUPLES + .filter(([, source]: string[]) => readLF(source).includes(`uses: ${SQUAD_INIT_ACTION}@`)) + .map(([name]: string[]) => name); +const MIGRATION_WARNINGS = SQUAD_INIT_WORKFLOWS.map(name => `.github/workflows/${name}.md: ${SAFE_UPDATE_WARNING}`); +const MIGRATION_CHANGES = [ + 'New unapproved action(s):', + ` - ${SQUAD_INIT_ACTION}`, + 'Previously-approved action(s) removed:', + ` - ${LEGACY_SQUAD_INIT_ACTION}`, +]; + +function textRegions(content: string, name: string): string[][] { + const pattern = new RegExp(`^\\n\`\`\`text\\n([\\s\\S]*?)\\n\`\`\`\\n$`, 'gm'); + return [...content.matchAll(pattern)].map(match => match[1].split('\n')); +} + +function assertMigrationWarningContract(content: string): void { + expect(textRegions(content, 'compile-warning-migration-allowlist'), 'one region with exactly the four warnings') + .toEqual([MIGRATION_WARNINGS]); + expect(textRegions(content, 'compile-warning-migration-changes'), 'one region with exactly the owner replacement') + .toEqual([MIGRATION_CHANGES]); + const flat = content.replace(/\s+/g, ' '); + expect(flat).toContain('**Legacy-identity migration only.**'); + expect(flat).toContain( + `whose locks committed at \`HEAD\` still use \`${LEGACY_SQUAD_INIT_ACTION}\`, gh-aw v0.91.5 compares safe updates ` + + 'against those committed locks, and `--approve` cannot clear that comparison before the upgrade is committed.', + ); + expect(flat).toContain( + 'During that migration, also require exactly these four warnings, one occurrence of each exact diagnostic header', + ); + expect(flat).toContain('with no other secret, action, redirect, or change'); + expect(flat).toContain('The summary is then `Compiled 8 workflows: 8 succeeded, 6 warnings`.'); + expect(flat).toContain('`Compiled 8 workflows: 8 succeeded, 2 warnings` (`6 warnings` during that migration)'); +} + +interface CompileWarning { + header: string; + changes: string[]; +} + +function parseCompileOutput(output: string): { warnings: CompileWarning[]; summary?: string } { + const lines = output.replace(/\r\n/g, '\n').split('\n'); + const warnings: CompileWarning[] = []; + lines.forEach((line, index) => { + const match = /^(.+?\.md): (warning: .+)$/.exec(line); + if (!match) return; + const changes: string[] = []; + if (match[2] === SAFE_UPDATE_WARNING) { + for (const next of lines.slice(index + 1)) { + if (next === 'Remediation options:') break; + if (next.trim()) changes.push(next); + } + } + // gh-aw prints native path separators; the documented gate uses POSIX paths. + warnings.push({ header: `${match[1].replaceAll('\\', '/')}: ${match[2]}`, changes }); + }); + return { warnings, summary: /Compiled \d+ workflows?: \d+ succeeded, \d+ warnings?/.exec(output)?.[0] }; +} + +/** Applies a document's strict-compile warning gate to real compiler output; returns its STOP reasons. */ +function compileGateFailures(content: string, output: string, migration: boolean): string[] { + const [base = []] = textRegions(content, 'compile-warning-allowlist'); + const [migrationWarnings = []] = migration ? textRegions(content, 'compile-warning-migration-allowlist') : []; + const [migrationChanges = []] = textRegions(content, 'compile-warning-migration-changes'); + const allowed = [...base, ...migrationWarnings]; + const { warnings, summary } = parseCompileOutput(output); + const failures: string[] = []; + for (const { header, changes } of warnings) { + if (!allowed.includes(header)) { + failures.push(`undocumented warning: ${header}`); + } else if (header.endsWith(SAFE_UPDATE_WARNING) && changes.join('\n') !== migrationChanges.join('\n')) { + failures.push(`undocumented safe-update change in ${header}: ${changes.join(' | ')}`); + } + } + for (const header of allowed) { + const count = warnings.filter(warning => warning.header === header).length; + if (count !== 1) failures.push(`expected one "${header}", found ${count}`); + } + const expectedSummary = `Compiled 8 workflows: 8 succeeded, ${allowed.length} warnings`; + if (summary !== expectedSummary) failures.push(`expected summary "${expectedSummary}", found "${summary}"`); + return failures; +} + +const MIGRATION_REVISION = 'e'.repeat(40); + +/** Writes the package workflows that `gh aw add @` installs, with a squad-init owner. */ +function installPackageWorkflows(root: string, packageName: string, action: string): void { + for (const [name, source] of WORKFLOW_TUPLES as string[][]) { + const text = readLF(source).replaceAll(`uses: ${SQUAD_INIT_ACTION}@`, `uses: ${action}@`); + const end = text.indexOf('\n---\n', 4); + writeFileSync( + resolve(root, `.github/workflows/${name}.md`), + `${text.slice(0, end)}\nsource: ${packageName}@${MIGRATION_REVISION}${text.slice(end)}`, + ); + } +} + +/** + * Commits locks compiled under the legacy squad-init owner, then reinstalls under the canonical + * owner and runs the strict compiles a migration runs: with --approve, without it, and after the + * upgrade commit. gh-aw v0.91.5 takes the safe-update baseline from the locks committed at HEAD. + */ +function compileCommittedLegacyMigration(): { approved: string; upgraded: string; committed: string } { + const root = mkdtempSync(resolve(ROOT, '.squad-legacy-lock-migration-')); + const git = (...args: string[]) => execFileSync('git', [ + '-c', 'user.name=Test', '-c', 'user.email=test@example.invalid', + '-c', 'core.hooksPath=/dev/null', '-c', 'commit.gpgsign=false', ...args, + ], { cwd: root, encoding: 'utf8' }); + const binary = process.env.SQUAD_GH_AW_BIN || 'gh'; + const prefix = process.env.SQUAD_GH_AW_BIN ? [] : ['aw']; + const compile = (...flags: string[]) => { + const result = spawnSync(binary, [...prefix, 'compile', '--strict', '--no-check-update', ...flags], { + cwd: root, encoding: 'utf8', env: { ...process.env, NO_COLOR: '1' }, timeout: 300_000, + }); + const output = `${result.stdout}${result.stderr}`; + expect(result.error ?? result.status, `gh aw compile --strict ${flags.join(' ')} failed:\n${output}`).toBe(0); + return output; + }; + try { + git('init', '--quiet'); + git('config', 'core.autocrlf', 'false'); + // Like a real consumer: without a remote, gh-aw adds schedule-scattering warnings. + git('remote', 'add', 'origin', 'https://github.com/octo/example.git'); + mkdirSync(resolve(root, '.github/workflows'), { recursive: true }); + cpSync(resolve(ROOT, 'workflows/shared'), resolve(root, '.github/workflows/shared'), { recursive: true }); + installPackageWorkflows(root, LEGACY_PACKAGE_NAME, LEGACY_SQUAD_INIT_ACTION); + compileWithPinnedActions(root); + for (const name of SQUAD_INIT_WORKFLOWS) { + expect(readFileSync(resolve(root, `.github/workflows/${name}.lock.yml`), 'utf8'), + `the committed legacy ${name}.lock.yml must use ${LEGACY_SQUAD_INIT_ACTION}`) + .toContain(`uses: ${LEGACY_SQUAD_INIT_ACTION}@`); + } + git('add', '--', '.github'); + git('commit', '--quiet', '-m', `legacy ${LEGACY_PACKAGE_NAME} install`); + installPackageWorkflows(root, PACKAGE_NAME, SQUAD_INIT_ACTION); + const approved = compile('--approve'); + const upgraded = compile(); + git('add', '--', '.github'); + git('commit', '--quiet', '-m', `move to ${PACKAGE_NAME}`); + return { approved, upgraded, committed: compile() }; + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + describe.each([CANONICAL, ...MIRRORS, GUIDE])('%s strict compile warning contract', path => { const content = readLF(path); @@ -241,6 +398,166 @@ describe.each([CANONICAL, ...MIRRORS, GUIDE])('%s strict compile warning contrac expect(mutated).not.toBe(content); expect(() => assertCompileWarningContract(mutated)).toThrow(); }); + + it('allows the four squad-init replacement warnings only during the legacy-identity migration', () => { + expect(SQUAD_INIT_WORKFLOWS, 'package workflows that run squad-init').toHaveLength(4); + assertMigrationWarningContract(content); + }); + + it.each([ + ['a fifth workflow', MIGRATION_WARNINGS.join('\n'), + `${MIGRATION_WARNINGS.join('\n')}\n.github/workflows/squad-review.md: ${SAFE_UPDATE_WARNING}`], + ['a missing workflow', MIGRATION_WARNINGS.join('\n'), MIGRATION_WARNINGS.slice(1).join('\n')], + ['a new secret', MIGRATION_CHANGES.join('\n'), + `New restricted secret(s):\n - ACME_DEPLOY_KEY\n${MIGRATION_CHANGES.join('\n')}`], + ['another removed action', MIGRATION_CHANGES.join('\n'), + MIGRATION_CHANGES.join('\n').replace(LEGACY_SQUAD_INIT_ACTION, 'acme/squad/.github/actions/squad-init')], + ['every upgrade', '**Legacy-identity migration only.**', '**Every upgrade.**'], + ])('rejects a migration contract mutation allowing %s', (_label, original, replacement) => { + const mutated = content.replace(original, replacement); + expect(mutated).not.toBe(content); + expect(() => assertMigrationWarningContract(mutated)).toThrow(); + }); +}); + +const UPGRADE_SHA = 'd'.repeat(40); +const UPGRADE_CLEANUP = [LEGACY_OWNERSHIP, UNOWNED_MUTABLE_ROUTER_SKILL]; + +function upgradeBlock(path: string, content: string): string { + const blocks = fencedBlocks(content).filter(block => block.includes(`${INSTALL_COMMAND} --force`)); + expect(blocks, `${path} must contain exactly one upgrade block`).toHaveLength(1); + // The guide's block starts with a placeholder that the reader replaces. + return blocks[0].replace(/^SQUAD_SHA="<40-character-commit-sha>"$/m, `SQUAD_SHA="${UPGRADE_SHA}"`); +} + +function runUpgradeBlock(block: string, addStatus: number) { + const root = mkdtempSync(resolve(ROOT, '.squad-upgrade-block-')); + try { + for (const path of UPGRADE_CLEANUP) { + mkdirSync(dirname(resolve(root, path)), { recursive: true }); + writeFileSync(resolve(root, path), `${path}\n`); + } + // No `set -e`: a pasted or agent-run block keeps going after a failed command, + // so the block itself must stop. + const script = ` +exec 3>&2 +gh() { + printf 'CALL gh %s\\n' "$*" >&3 + if [ "$1" = "api" ]; then printf '%s\\n' "${PACKAGE_NAME}"; return 0; fi + if [ "$1 $2" = "aw add" ]; then return "$ADD_STATUS"; fi + return 0 +} +node() { printf 'CALL node %s\\n' "$*" >&3; } +SQUAD_SHA="${UPGRADE_SHA}" +${block} +echo "UPGRADE BLOCK COMPLETED" +`; + const result = spawnSync('bash', ['-c', script], { + cwd: root, + encoding: 'utf8', + env: { ...process.env, ADD_STATUS: String(addStatus) }, + }); + return { + ...result, + calls: result.stderr.split('\n').filter(line => line.startsWith('CALL ')), + remaining: UPGRADE_CLEANUP.filter(path => existsSync(resolve(root, path))), + }; + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +describe.each([CANONICAL, ...MIRRORS, GUIDE])('%s upgrade block', path => { + const block = upgradeBlock(path, readLF(path)); + + it('stops before any cleanup when the forced reinstall fails', () => { + const result = runUpgradeBlock(block, 1); + const input = `${path} upgrade block with \`gh aw add ${PACKAGE_NAME}@${UPGRADE_SHA} --force\` exiting 1`; + expect(result.remaining, `${input} must keep ${UPGRADE_CLEANUP.join(' and ')}`).toEqual(UPGRADE_CLEANUP); + expect(result.status, `${input} must exit 1:\n${result.stderr}`).toBe(1); + expect(result.stderr, `${input} must say why it stopped`) + .toContain('STOP: gh aw add --force failed, so this block removed nothing.'); + expect(result.calls.at(-1), `${input} must not run anything after the reinstall`) + .toBe(`CALL gh aw add ${PACKAGE_NAME}@${UPGRADE_SHA} --force`); + expect(result.stdout, input).not.toContain('UPGRADE BLOCK COMPLETED'); + }); + + it('removes the superseded files and verifies after the reinstall succeeds', () => { + const result = runUpgradeBlock(block, 0); + const input = `${path} upgrade block with \`gh aw add ${PACKAGE_NAME}@${UPGRADE_SHA} --force\` exiting 0`; + expect(result.status, `${input}:\n${result.stderr}`).toBe(0); + expect(result.remaining, `${input} must delete ${UPGRADE_CLEANUP.join(' and ')}`).toEqual([]); + expect(result.calls, input).toEqual([ + 'CALL gh api --hostname github.com -H Accept: application/vnd.github.raw+json ' + + `repos/github/squad/contents/workflows/squad-workflows.manifest.json?ref=${UPGRADE_SHA} --jq .package`, + `CALL gh aw add ${PACKAGE_NAME}@${UPGRADE_SHA} --force`, + 'CALL node .github/workflows/shared/squad-install-verifier.mjs --materialize-runtime', + 'CALL gh aw compile --strict', + 'CALL node .github/workflows/shared/squad-install-verifier.mjs --verify-install ' + + `--source-revision ${UPGRADE_SHA} --strict-compile`, + ]); + expect(result.stdout, input).toContain('UPGRADE BLOCK COMPLETED'); + }); +}); + +describe('gh-aw legacy-identity migration with committed legacy locks', () => { + let outputs: { approved: string; upgraded: string; committed: string }; + beforeAll(() => { + outputs = compileCommittedLegacyMigration(); + }, 900_000); + + const headers = (output: string) => parseCompileOutput(output).warnings.map(warning => warning.header).sort(); + + it('repeats the squad-init owner replacement in every strict compile until the upgrade is committed', () => { + const summary = (output: string) => parseCompileOutput(output).summary; + expect(headers(outputs.approved), `gh aw compile --strict --approve output:\n${outputs.approved}`) + .toEqual([...COMPILE_WARNINGS].sort()); + expect(summary(outputs.approved), outputs.approved).toBe('Compiled 8 workflows: 8 succeeded, 2 warnings'); + expect(headers(outputs.upgraded), `gh aw compile --strict output after --approve:\n${outputs.upgraded}`) + .toEqual([...COMPILE_WARNINGS, ...MIGRATION_WARNINGS].sort()); + expect(summary(outputs.upgraded), outputs.upgraded).toBe('Compiled 8 workflows: 8 succeeded, 6 warnings'); + for (const warning of parseCompileOutput(outputs.upgraded).warnings) { + if (warning.header.endsWith(SAFE_UPDATE_WARNING)) expect(warning.changes, warning.header).toEqual(MIGRATION_CHANGES); + } + expect(headers(outputs.committed), `gh aw compile --strict output after the upgrade commit:\n${outputs.committed}`) + .toEqual([...COMPILE_WARNINGS].sort()); + expect(summary(outputs.committed), outputs.committed).toBe('Compiled 8 workflows: 8 succeeded, 2 warnings'); + }); + + describe.each([CANONICAL, ...MIRRORS, GUIDE])('%s warning gate', path => { + const content = readLF(path); + + it('accepts that migration compile, and those four warnings only during the migration', () => { + const accepted = compileGateFailures(content, outputs.upgraded, true); + expect(accepted, `${path} must accept the four squad-init replacement warnings of a migration whose ` + + `legacy locks are committed; compiler warnings: ${headers(outputs.upgraded).join('; ')}`).toEqual([]); + expect(compileGateFailures(content, outputs.upgraded, false).sort(), + `${path} must stop on those warnings outside the migration`).toEqual([ + ...MIGRATION_WARNINGS.map(header => `undocumented warning: ${header}`), + 'expected summary "Compiled 8 workflows: 8 succeeded, 2 warnings", ' + + 'found "Compiled 8 workflows: 8 succeeded, 6 warnings"', + ].sort()); + expect(compileGateFailures(content, outputs.committed, false), + `${path} must accept the compile after the upgrade commit`).toEqual([]); + }); + + const safeUpdateBlock = (workflow: string) => + `.github/workflows/${workflow}.md: ${SAFE_UPDATE_WARNING}\n\n${MIGRATION_CHANGES.join('\n')}\n\nRemediation options:\n`; + + it.each([ + ['a new secret', (output: string) => output.replace(MIGRATION_CHANGES[0], + `New restricted secret(s):\n - ACME_DEPLOY_KEY\n${MIGRATION_CHANGES[0]}`)], + ['another removed action', (output: string) => output.replace(MIGRATION_CHANGES[3], + ' - acme/squad/.github/actions/squad-init')], + ['a replacement in another workflow', (output: string) => `${output}\n${safeUpdateBlock('squad-review')}`], + ['a duplicated replacement', (output: string) => `${output}\n${safeUpdateBlock(SQUAD_INIT_WORKFLOWS[0])}`], + ])('stops on a migration compile with %s', (label, mutate) => { + const mutated = mutate(outputs.upgraded); + expect(mutated).not.toBe(outputs.upgraded); + expect(compileGateFailures(content, mutated, true), `${path} must stop on a migration compile with ${label}`) + .not.toEqual([]); + }); + }); }); describe('gh-aw-enlistment skill', () => { @@ -402,9 +719,12 @@ describe('gh-aw-enlistment skill', () => { ); expect(flat, why).toContain('**STOP** on any other new secret or action.'); expect(flat, 'skill must say how to stay on a legacy-only revision').toContain( - `with \`${LEGACY_INSTALL_PREFIX}\${SQUAD_SHA}" --force\`, and skip the second \`rm -f\`: ` + 'drop the identity check below, keep the `gh aw add` line\'s STOP guard but replace its command ' + + `with \`${LEGACY_INSTALL_PREFIX}\${SQUAD_SHA}" --force\`, and skip the second \`rm -f\`: ` + 'that ownership record is the only one such an installation has.', ); + expect(flat, 'skill must say a failed reinstall removes nothing') + .toContain('If the forced reinstall fails, the block stops before it removes anything.'); const upgrade = fencedBlocks(content).find(block => block.includes(`${INSTALL_COMMAND} --force`)); expect(upgrade, 'skill must include an executable upgrade block').toBeDefined(); const steps = [ @@ -426,15 +746,24 @@ describe('gh-aw-enlistment skill', () => { }; const compileStep = stepNumber('Always run the final strict compile WITHOUT `--approve`'); const stageStep = stepNumber('Inspect generated files, then stage only the documented surfaces'); + const installStep = stepNumber('Install the native package at an explicit, maintainer-approved revision'); + expect(flat, 'the migration allowlist must name the step that holds the upgrade block') + .toContain(`**Legacy-identity migration only.** While step ${installStep}'s upgrade block moves an installation`); const compileSection = content.slice( content.indexOf(`### ${compileStep}. `), content.indexOf(`### ${Number(compileStep) + 1}. `), ); expect(compileSection.replace(/\s+/g, ' '), `step ${compileStep} must hold the two-warning check`) .toContain('require exactly two warnings'); + expect(compileSection, `step ${compileStep} must hold the legacy-identity migration allowlist`) + .toContain(''); + expect(flat, 'skill must route the migration warnings to the warning check by its current number').toContain( + 'Until the upgrade is committed, every strict compile repeats that change as four warnings, ' + + `which step ${compileStep} allows only for this migration.`, + ); expect(flat, 'the upgrade block must hand off to the warning check and staging steps by their current numbers') - .toContain(`Apply step ${compileStep}'s two-warning check to the compile output above, ` - + `then stage and verify as in step ${stageStep}.`); + .toContain(`Apply step ${compileStep}'s warning check, including its legacy-identity migration allowlist, ` + + `to the compile output above, then stage and verify as in step ${stageStep}.`); }); it('gates installation on the revision declaring the canonical package identity', () => { @@ -838,9 +1167,18 @@ describe('gh-aw-enlistment skill', () => { expect(upgrade.indexOf(gate), 'Upgrading must run the identity gate').toBeGreaterThan(-1); expect(upgrade.indexOf(gate), 'Upgrading must check the identity before reinstalling').toBeLessThan(add); expect(flatUpgrade, 'Upgrading must say how to stay on a legacy-only revision').toContain( - `with \`${LEGACY_INSTALL_PREFIX}\${SQUAD_SHA}" --force\`, and skip the second \`rm -f\`: ` + 'drop the identity check, keep the `gh aw add` line\'s STOP guard but replace its command ' + + `with \`${LEGACY_INSTALL_PREFIX}\${SQUAD_SHA}" --force\`, and skip the second \`rm -f\`: ` + 'that ownership record is the only one such an installation has.', ); + expect(flatUpgrade, 'Upgrading must say a failed reinstall removes nothing') + .toContain('If the forced reinstall fails, the block stops before it removes anything.'); + expect(flatUpgrade, 'Upgrading must route the migration warnings to the strict compile allowlist').toContain( + 'Until the upgrade is committed, every strict compile repeats that change as four warnings; accept them ' + + 'only through the legacy-identity migration allowlist in ' + + '[Strictly compile the installed workflows](#strictly-compile-the-installed-workflows).', + ); + expect(guide, 'the linked strict compile section must exist').toContain('\n### Strictly compile the installed workflows\n'); }); }); diff --git a/test/gh-aw-install-staging.test.ts b/test/gh-aw-install-staging.test.ts index 893bf3877..a4cfb3c16 100644 --- a/test/gh-aw-install-staging.test.ts +++ b/test/gh-aw-install-staging.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { execFileSync, spawnSync } from 'node:child_process'; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; @@ -75,6 +75,33 @@ function seedUnownedMutableRouter(root: string): void { ].join('\n')); } +/** The indented commands the verifier prints after a heading, as one shell script. */ +function printedCommands(stderr: string, heading: string): string { + const lines = stderr.replace(/\r\n/g, '\n').split('\n'); + const start = lines.findIndex(line => line.startsWith(heading)); + expect(start, `verifier output must print "${heading}":\n${stderr}`).toBeGreaterThan(-1); + const end = lines.findIndex((line, index) => index > start && !line.startsWith(' ')); + return lines.slice(start + 1, end < 0 ? undefined : end).map(line => line.slice(2)).join('\n'); +} + +function runPrintedCommands(root: string, commands: string, addStatus: number) { + // No `set -e`: pasted commands keep running after a failure unless they stop themselves. + const script = ` +exec 3>&2 +gh() { + printf 'CALL gh %s\\n' "$*" >&3 + if [ "$1 $2" = "aw add" ]; then return "$ADD_STATUS"; fi + return 0 +} +node() { printf 'CALL node %s\\n' "$*" >&3; } +${commands} +`; + const result = spawnSync('bash', ['-c', script], { + cwd: root, encoding: 'utf8', env: { ...process.env, ADD_STATUS: String(addStatus) }, + }); + return result.stderr.split('\n').filter(line => line.startsWith('CALL ')); +} + afterEach(() => { vi.clearAllMocks(); for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); @@ -233,10 +260,10 @@ describe('gh-aw: verified ownership staging under consumer ignore rules', () => `Unowned mutable gh-aw router skill must be removed: ${UNOWNED_MUTABLE_ROUTER_SKILL}`, ); expect(rejected.stderr, `recovery must also delete ${LEGACY_OWNERSHIP}`).toContain([ - ` gh aw add github/squad/workflows@${revision} --force`, - ' rm -f .github/aw/packages/bradygaster-squad-workflows-3632054824e8.json', - ` rm -f ${UNOWNED_MUTABLE_ROUTER_SKILL}`, - ' gh aw compile --strict', + ` gh aw add github/squad/workflows@${revision} --force &&`, + ' rm -f .github/aw/packages/bradygaster-squad-workflows-3632054824e8.json &&', + ` rm -f ${UNOWNED_MUTABLE_ROUTER_SKILL} &&`, + ' gh aw compile --strict &&', ' node .github/workflows/shared/squad-install-verifier.mjs --verify-install --strict-compile', ].join('\n')); @@ -331,7 +358,8 @@ describe('gh-aw: verified ownership staging under consumer ignore rules', () => + `of this ${LEGACY_PACKAGE_NAME} installation; do not delete it.`, ); expect(failure, state).toContain( - `Reinstall with gh aw add ${PACKAGE_NAME}@${revision} --force, delete ${LEGACY_OWNERSHIP}, and stage both changes`, + `Reinstall with gh aw add ${PACKAGE_NAME}@${revision} --force; only after it succeeds, ` + + `delete ${LEGACY_OWNERSHIP} and stage both changes`, ); expect(failure, state).not.toContain('delete it and stage the deletion'); }, 120_000); @@ -344,7 +372,7 @@ describe('gh-aw: verified ownership staging under consumer ignore rules', () => expect(legacy.stderr, `a ${LEGACY_PACKAGE_NAME} install must print the move notice`).toContain([ `Notice: verified under the legacy ${LEGACY_PACKAGE_NAME} package identity, which remains supported.`, `To move to ${PACKAGE_NAME}, follow the gh-aw guide's Upgrading steps, starting with:`, - ` gh aw add ${PACKAGE_NAME}@${revision} --force`, + ` gh aw add ${PACKAGE_NAME}@${revision} --force &&`, ` rm -f ${LEGACY_OWNERSHIP}`, ].join('\n')); const canonical = spawnSync(process.execPath, args, { cwd: consumer(), encoding: 'utf8' }); @@ -352,6 +380,46 @@ describe('gh-aw: verified ownership staging under consumer ignore rules', () => expect(canonical.stderr, `a ${PACKAGE_NAME} install must not print the legacy notice`).not.toContain('Notice:'); }, 120_000); + it.each([ + [1, [LEGACY_OWNERSHIP, UNOWNED_MUTABLE_ROUTER_SKILL], []], + [0, [], ['CALL gh aw compile --strict', + 'CALL node .github/workflows/shared/squad-install-verifier.mjs --verify-install --strict-compile']], + ] as const)('runs the printed recovery cleanup only after the reinstall succeeds (gh aw add exits %i)', + (addStatus, remaining, afterReinstall) => { + const root = mkdtempSync(join(tmpdir(), 'squad-install-staging-')); + roots.push(root); + write(root, LEGACY_OWNERSHIP, `${JSON.stringify({ package: LEGACY_PACKAGE_NAME })}\n`); + seedUnownedMutableRouter(root); + const failed = spawnSync(process.execPath, [verifier, '--verify-install', '--source-revision', revision], { + cwd: root, encoding: 'utf8', + }); + expect(failed.status, failed.stderr).toBe(1); + const commands = printedCommands(failed.stderr, 'Safe recovery'); + const calls = runPrintedCommands(root, commands, addStatus); + const input = `printed recovery commands with gh aw add exiting ${addStatus}:\n${commands}`; + expect([LEGACY_OWNERSHIP, UNOWNED_MUTABLE_ROUTER_SKILL].filter(path => existsSync(join(root, path))), + `files left by the ${input}`).toEqual(remaining); + expect(calls, input).toEqual([`CALL gh aw add ${PACKAGE_NAME}@${revision} --force`, ...afterReinstall]); + }); + + it.each([ + [1, [LEGACY_OWNERSHIP]], + [0, []], + ] as const)('removes the legacy record with the printed move commands only after the reinstall succeeds ' + + '(gh aw add exits %i)', (addStatus, remaining) => { + const root = consumer(LEGACY_PACKAGE_NAME); + const verified = spawnSync(process.execPath, [verifier, '--verify-install', '--source-revision', revision], { + cwd: root, encoding: 'utf8', + }); + expect(verified.status, verified.stderr).toBe(0); + const commands = printedCommands(verified.stderr, `To move to ${PACKAGE_NAME}`); + const calls = runPrintedCommands(root, commands, addStatus); + const input = `printed legacy move commands with gh aw add exiting ${addStatus}:\n${commands}`; + expect([LEGACY_OWNERSHIP].filter(path => existsSync(join(root, path))), `files left by the ${input}`) + .toEqual(remaining); + expect(calls, input).toEqual([`CALL gh aw add ${PACKAGE_NAME}@${revision} --force`]); + }, 120_000); + it('fails clearly when Git cannot force-stage the required ignored metadata', () => { const root = consumer(); write(root, '.git/index.lock', 'locked\n'); diff --git a/workflows/package/squad-bootstrap.md b/workflows/package/squad-bootstrap.md index 152a0ec51..580c67ee9 100644 --- a/workflows/package/squad-bootstrap.md +++ b/workflows/package/squad-bootstrap.md @@ -153,7 +153,7 @@ pre-agent-steps: node --check "$path" >/dev/null } # BEGIN GENERATED RESOURCE DIGESTS - check_hash "$install_verifier" "eb6709d221e55f87d3fdb522a4f2bc4c21cc8bf96cb38908f93e2d5817f96735" + check_hash "$install_verifier" "0f5cadf31eda9d059795a2356ed1a42446912de9ce0401b023888cc3e6d86edd" check_hash "$cast_validator" "15f0d47917d269b5b967dc2a782a0eb761c39af7a92ddb64fed687a427070760" check_hash "$bootstrap_validator" "d552d041f006249bfaa67b10a75877f813796b7698c718ac7eb7cf16f3a72e31" # END GENERATED RESOURCE DIGESTS diff --git a/workflows/shared/squad-install-verifier.mjs b/workflows/shared/squad-install-verifier.mjs index 39bbcb73c..b3b33a20b 100644 --- a/workflows/shared/squad-install-verifier.mjs +++ b/workflows/shared/squad-install-verifier.mjs @@ -1142,8 +1142,8 @@ export function verifyStagedInstall(root, { expectedRevision = '', stageOwnershi throw new Error(path === OWNERSHIP_DESTINATION ? `Squad package ownership record ${path} (${PACKAGE_NAME}) remains in the staged tree of this ` + `${result.packageName} installation; do not delete it. Reinstall with ` - + `gh aw add ${PACKAGE_NAME}@${result.revision} --force, delete ${ownershipDestination}, ` - + 'and stage both changes before commit/push.' + + `gh aw add ${PACKAGE_NAME}@${result.revision} --force; only after it succeeds, delete ` + + `${ownershipDestination} and stage both changes before commit/push.` : `Superseded Squad package ownership record remains in staged tree: ${path}; ` + 'delete it and stage the deletion before commit/push.'); } @@ -1227,15 +1227,22 @@ export function writeLocalTestOwnership(root, revision) { })); } +// One && chain, so a failed reinstall stops before any ownership-record or router cleanup. +function chainedCommands(commands) { + return commands.map((command, index) => ` ${command}${index < commands.length - 1 ? ' &&' : ''}`); +} + function recoveryMessage(revision) { const ref = REVISION_PATTERN.test(revision) ? revision : '<40-character-squad-commit-sha>'; return [ - 'Safe recovery:', - ` gh aw add ${PACKAGE_NAME}@${ref} --force`, - ...LEGACY_OWNERSHIP_DESTINATIONS.map(path => ` rm -f ${path}`), - ` rm -f ${UNOWNED_MUTABLE_ROUTER_SKILL}`, - ' gh aw compile --strict', - ' node .github/workflows/shared/squad-install-verifier.mjs --verify-install --strict-compile', + 'Safe recovery (one command; it stops at the first failure):', + ...chainedCommands([ + `gh aw add ${PACKAGE_NAME}@${ref} --force`, + ...LEGACY_OWNERSHIP_DESTINATIONS.map(path => `rm -f ${path}`), + `rm -f ${UNOWNED_MUTABLE_ROUTER_SKILL}`, + 'gh aw compile --strict', + 'node .github/workflows/shared/squad-install-verifier.mjs --verify-install --strict-compile', + ]), ].join('\n'); } @@ -1243,8 +1250,10 @@ function legacyIdentityNotice(packageName, revision) { return [ `Notice: verified under the legacy ${packageName} package identity, which remains supported.`, `To move to ${PACKAGE_NAME}, follow the gh-aw guide's Upgrading steps, starting with:`, - ` gh aw add ${PACKAGE_NAME}@${revision} --force`, - ...LEGACY_OWNERSHIP_DESTINATIONS.map(path => ` rm -f ${path}`), + ...chainedCommands([ + `gh aw add ${PACKAGE_NAME}@${revision} --force`, + ...LEGACY_OWNERSHIP_DESTINATIONS.map(path => `rm -f ${path}`), + ]), ].join('\n'); } diff --git a/workflows/skills/gh-aw-enlistment/SKILL.md b/workflows/skills/gh-aw-enlistment/SKILL.md index 91043f57a..9839df422 100644 --- a/workflows/skills/gh-aw-enlistment/SKILL.md +++ b/workflows/skills/gh-aw-enlistment/SKILL.md @@ -260,18 +260,21 @@ installation to a revision that declares `github/squad/workflows` with the block below. gh-aw keeps the superseded `.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json` after the forced reinstall, so the block deletes exactly that file before verifying; the -verifier fails closed while both ownership records exist. That first upgrade +verifier fails closed while both ownership records exist. If the forced +reinstall fails, the block stops before it removes anything. That first upgrade also makes `gh aw add` report one safe-update change: action `github/squad/.github/actions/squad-init` added and `bradygaster/squad/.github/actions/squad-init` removed. It is the same squad-init action under the repository's new owner; list it in the PR's -security review note. **STOP** on any other new secret or action. Until an +security review note. **STOP** on any other new secret or action. Until the +upgrade is committed, every strict compile repeats that change as four +warnings, which step 5 allows only for this migration. Until an installation moves, `--verify-install` still passes and prints a notice naming the legacy identity. To stay on a revision that declares -`bradygaster/squad/workflows`, replace the identity check and the `gh aw add` -line below with `gh aw add "bradygaster/squad/workflows@${SQUAD_SHA}" --force`, -and skip the second `rm -f`: that ownership record is the only one such an -installation has. +`bradygaster/squad/workflows`, drop the identity check below, keep the +`gh aw add` line's STOP guard but replace its command with +`gh aw add "bradygaster/squad/workflows@${SQUAD_SHA}" --force`, and skip the +second `rm -f`: that ownership record is the only one such an installation has. ```bash : "${SQUAD_SHA:?STOP: set SQUAD_SHA to an explicit, maintainer-approved 40-character Squad commit SHA before upgrading.}" @@ -292,7 +295,10 @@ test "${squad_package}" = "github/squad/workflows" || { fi exit 1 } -gh aw add "github/squad/workflows@${SQUAD_SHA}" --force +gh aw add "github/squad/workflows@${SQUAD_SHA}" --force || { + echo "STOP: gh aw add --force failed, so this block removed nothing. Fix the error above, then rerun the block." >&2 + exit 1 +} rm -f .github/skills/agentic-workflows/SKILL.md rm -f .github/aw/packages/bradygaster-squad-workflows-3632054824e8.json node .github/workflows/shared/squad-install-verifier.mjs --materialize-runtime @@ -303,7 +309,7 @@ node .github/workflows/shared/squad-install-verifier.mjs \ --strict-compile ``` -Apply step 5's two-warning check to the compile output above, then stage and verify as in step 7. +Apply step 5's warning check, including its legacy-identity migration allowlist, to the compile output above, then stage and verify as in step 7. Report/proposal-only is the default. Ordinary fixes require the explicit `"squadRetroAutoImplement": "allow"` setting in `.squad/config.json`; @@ -390,11 +396,48 @@ least-privilege jobs, advisory verdict, and independent human approval. `pull_request_target` is not generally safe; this narrow exception neither weakens those controls nor authorizes PR-head execution. +**Legacy-identity migration only.** While step 3's upgrade block moves an +installation whose locks committed at `HEAD` still use +`bradygaster/squad/.github/actions/squad-init`, gh-aw v0.91.5 compares safe +updates against those committed locks, and `--approve` cannot clear that +comparison before the upgrade is committed. During that migration, also +require exactly these four warnings, one occurrence of each exact diagnostic +header: + + +```text +.github/workflows/squad.md: warning: safe update mode detected unapproved changes +.github/workflows/squad-implement-worker.md: warning: safe update mode detected unapproved changes +.github/workflows/squad-deps-worker.md: warning: safe update mode detected unapproved changes +.github/workflows/squad-retro.md: warning: safe update mode detected unapproved changes +``` + + +Each of these four warnings must report exactly this squad-init owner +replacement, with no other secret, action, redirect, or change: + + +```text +New unapproved action(s): + - github/squad/.github/actions/squad-init +Previously-approved action(s) removed: + - bradygaster/squad/.github/actions/squad-init +``` + + +The remediation text under each warning and the closing `SECURITY REVIEW +REQUIRED` prompts belong to these four warnings; they are not additional +warnings. The summary is then `Compiled 8 workflows: 8 succeeded, 6 warnings`. +Name the replacement in the PR's security review note. The four warnings no +longer appear once the upgrade is committed. + **STOP** on any error, or on **any additional warning** beyond these two exact -documented diagnostics. Also STOP if either warning is missing, duplicated, -changed, or attributed to another path, if the summary is not -`Compiled 8 workflows: 8 succeeded, 2 warnings`, or if any required control is -absent. Do not suppress warnings or use `--approve` to bypass this gate. +documented diagnostics and, during that migration only, the four exact +squad-init replacement warnings. Also STOP if an allowed warning is missing, +duplicated, changed, or attributed to another path, if the summary is not +`Compiled 8 workflows: 8 succeeded, 2 warnings` (`6 warnings` during that +migration), or if any required control is absent. Do not suppress warnings or +use `--approve` to bypass this gate. ### 6. Require the verifier to prove the complete consumer contract @@ -672,8 +715,9 @@ gh pr merge --squash # auto-merge before human review. NEVER. - ❌ **Treating `--approve` as the final compile.** Always finish with a plain `gh aw compile --strict` (no `--approve`). - ❌ **Tolerating extra warnings.** Only the documented `squad-review.md` - `pull_request_target` advisory and `squad.md` bot-trigger warning are allowed; - every other warning or error halts the run. + `pull_request_target` advisory and `squad.md` bot-trigger warning are allowed, + plus step 5's four squad-init replacement warnings during the legacy-identity + migration only; every other warning or error halts the run. - ❌ **Committing diagnostics.** Never commit `.github/aw/logs/` output; add the log `.gitignore` if missing. - ❌ **Clobbering existing workflows.** The install is additive; preserve unrelated diff --git a/workflows/squad-bootstrap.md b/workflows/squad-bootstrap.md index f5635a5d6..68780cd8d 100644 --- a/workflows/squad-bootstrap.md +++ b/workflows/squad-bootstrap.md @@ -160,7 +160,7 @@ pre-agent-steps: node --check "$path" >/dev/null } # BEGIN GENERATED RESOURCE DIGESTS - check_hash "$install_verifier" "eb6709d221e55f87d3fdb522a4f2bc4c21cc8bf96cb38908f93e2d5817f96735" + check_hash "$install_verifier" "0f5cadf31eda9d059795a2356ed1a42446912de9ce0401b023888cc3e6d86edd" check_hash "$cast_validator" "15f0d47917d269b5b967dc2a782a0eb761c39af7a92ddb64fed687a427070760" check_hash "$bootstrap_validator" "d552d041f006249bfaa67b10a75877f813796b7698c718ac7eb7cf16f3a72e31" # END GENERATED RESOURCE DIGESTS diff --git a/workflows/squad-workflows.manifest.json b/workflows/squad-workflows.manifest.json index 5041fd40e..da3ac93f5 100644 --- a/workflows/squad-workflows.manifest.json +++ b/workflows/squad-workflows.manifest.json @@ -69,9 +69,9 @@ "source": "workflows/package/squad-bootstrap.md", "destination": ".github/workflows/squad-bootstrap.md", "lock": ".github/workflows/squad-bootstrap.lock.yml", - "source_sha256": "fa4e4eef298dc7162ce44c1d33b9750bce521172867b247f933a943e0aebc88f", - "lock_sha256": "91b1ce3fee7ca3f195536fe27446f2a19477a9023d0416db86e92a3e097b7426", - "package_lock_sha256": "c064a6c063a909e9c18b58bf491d4a84bf13e52e80402e5f0728801b96d5884f" + "source_sha256": "c2c1814a95da3219c32126b31efae9f5e660d6381f73bc6692076ea29a1e50bf", + "lock_sha256": "964c529371eb0e3348c5db99ac6c1ecbb00a0650c734f238cdaa6495a1ca325d", + "package_lock_sha256": "e5768109b59bd3fb58add777ef02fa964c2f9e24f5f006b232d63b81eba08324" }, { "name": "squad-command-router", @@ -119,8 +119,8 @@ }, { "name": "squad-bootstrap", - "lock_sha256": "9424095fee656651f943ad6480cb16a1d83581f81cb04b806fd11ba308b9ec75", - "package_lock_sha256": "c9a72a3757351109b59b769b0b1f9481f269eb63c919bcc10610cb8d76d93274" + "lock_sha256": "82d11d6bd55847a642251545b9721d454b63d507ec509a7aac54987d403df82d", + "package_lock_sha256": "ecf0740305ad948948273f302eeca0ae4f6cacd6017f71457991265c56816cc8" }, { "name": "squad-command-router", @@ -169,7 +169,7 @@ "package_destination": ".github/workflows/shared/squad-install-verifier.mjs", "destination": ".github/workflows/shared/squad-install-verifier.mjs", "ownership": "manifest", - "sha256": "eb6709d221e55f87d3fdb522a4f2bc4c21cc8bf96cb38908f93e2d5817f96735" + "sha256": "0f5cadf31eda9d059795a2356ed1a42446912de9ce0401b023888cc3e6d86edd" }, { "path": "shared/squad-command-contract.mjs", @@ -305,7 +305,7 @@ "name": "gh-aw-enlistment", "source": "workflows/skills/gh-aw-enlistment/SKILL.md", "destination": ".github/skills/gh-aw-enlistment/SKILL.md", - "sha256": "2a6b70ae2bbe036eed1ace0bafa746653b8cc1cf1606c8b14a2ad97c69c3a82a" + "sha256": "e42652883653289ea5cecb69d51fbb7b6bbd372514666a09cff5920a492c01a8" } ], "bootstrap": { From c1fca3d5a75d475fe385266063fbb5d4c8d5c455 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Sun, 8 Nov 2026 23:58:44 +0200 Subject: [PATCH 3/6] fix(gh-aw): pin squad-init downloads and harden upgrade recovery Address four Copilot review findings on #2410. Pass `repository: 'github/squad'` to the pinned squad-init action. At 5c662ba015ec its `repository` input still defaults to bradygaster/squad, the source of the release archive, its SHA256SUMS checksum and the latest-release lookup. The new `uses:` owner did not change that, and the old path is now a fork with no releases, so whoever controls it would supply both the archive and its checksum. Regenerate the four package workflows and the integrity manifest. Stop the guide's Upgrading block and the README install block on a SQUAD_SHA that is not a 40-character lowercase commit SHA, before the manifest contents API call, with the guard and message the other guide blocks already use. The contents API accepts branch names, so `dev` passed the identity check: the upgrade reinstalled and deleted the legacy ownership record before --source-revision rejected it, and the README block installed and pushed the moving ref. The skill and its mirrors were already guarded. Reject an exact ownership record next to a case-variant one. Reinstalling GitHub/Squad/workflows as github/squad/workflows leaves both records, because gh-aw names each by a digest of the identity as typed, and the verifier used the exact one while ignoring the other. The error names both records and the one to delete. Run --materialize-runtime before `gh aw compile --strict` in the printed recovery chain, as the documented install and upgrade sequences do. `gh aw add --force` rewrites only the package-owned runtime copies, so a stale or missing materialized file, such as .github/workflows/shared/squad.md, failed verification again after a successful reinstall. Tests resolve the activation step's squad-init inputs into the pinned-action download harness, run every block that reads the manifest with SQUAD_SHA=dev, verify an install with both records, and run the printed recovery against a stale and a missing materialized file. Each fails with its fix reverted. Refs #2403 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 4 + docs/src/content/docs/guide/gh-aw.md | 4 + test/gh-aw-enlistment-skill.test.ts | 87 +++++++++++++++++++++ test/gh-aw-install-staging.test.ts | 36 ++++++++- test/gh-aw-quality.test.ts | 26 ++++++ test/squad-cli-latest.test.ts | 42 +++++++--- workflows/package/squad-bootstrap.md | 2 +- workflows/package/squad-deps-worker.md | 1 + workflows/package/squad-implement-worker.md | 1 + workflows/package/squad-retro.md | 1 + workflows/package/squad.md | 1 + workflows/shared/squad-install-verifier.mjs | 19 ++++- workflows/shared/squad.md | 3 + workflows/squad-bootstrap.md | 2 +- workflows/squad-workflows.manifest.json | 54 ++++++------- 15 files changed, 237 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index a0b901945..7a8c40ae1 100644 --- a/README.md +++ b/README.md @@ -600,6 +600,10 @@ test "$(gh api "repos/${owner_repo}" --jq '.has_issues')" = "true" || { } SQUAD_SHA="<40-character-commit-sha>" # explicit, maintainer-approved; never `commits/dev` +[[ "${SQUAD_SHA}" =~ ^[0-9a-f]{40}$ ]] || { + echo "STOP: SQUAD_SHA must be an explicit 40-character Squad commit SHA, not a branch name or shortened hash." >&2 + exit 1 +} squad_package="$(gh api --hostname github.com -H 'Accept: application/vnd.github.raw+json' \ "repos/github/squad/contents/workflows/squad-workflows.manifest.json?ref=${SQUAD_SHA}" \ --jq '.package')" || squad_package="" diff --git a/docs/src/content/docs/guide/gh-aw.md b/docs/src/content/docs/guide/gh-aw.md index bccbd33c6..45253e95d 100644 --- a/docs/src/content/docs/guide/gh-aw.md +++ b/docs/src/content/docs/guide/gh-aw.md @@ -2588,6 +2588,10 @@ same native package as one unit: ```bash SQUAD_SHA="<40-character-commit-sha>" +[[ "${SQUAD_SHA}" =~ ^[0-9a-f]{40}$ ]] || { + echo "STOP: SQUAD_SHA must be an explicit 40-character Squad commit SHA, not a branch name or shortened hash." >&2 + exit 1 +} squad_package="$(gh api --hostname github.com -H 'Accept: application/vnd.github.raw+json' \ "repos/github/squad/contents/workflows/squad-workflows.manifest.json?ref=${SQUAD_SHA}" \ --jq '.package')" || squad_package="" diff --git a/test/gh-aw-enlistment-skill.test.ts b/test/gh-aw-enlistment-skill.test.ts index 323cf0e23..7fe4c6819 100644 --- a/test/gh-aw-enlistment-skill.test.ts +++ b/test/gh-aw-enlistment-skill.test.ts @@ -500,6 +500,93 @@ describe.each([CANONICAL, ...MIRRORS, GUIDE])('%s upgrade block', path => { }); }); +const MANIFEST_API = 'repos/github/squad/contents/workflows/squad-workflows.manifest.json'; +const BRANCH_REF = 'dev'; +const BRANCH_REF_CLEANUP = [UNOWNED_MUTABLE_ROUTER_SKILL, LEGACY_OWNERSHIP]; +const MANIFEST_BLOCK_SURFACES = [CANONICAL, ...MIRRORS, GUIDE, 'README.md'] as const; + +/** Every fenced block that reads the package manifest through the contents API, named by its fence line. */ +function manifestBlocks(path: string): { name: string; block: string }[] { + const content = readLF(path); + return [...content.matchAll(/^```[^\n]*\n([\s\S]*?)^```$/gm)] + .filter(match => match[1].includes(MANIFEST_API)) + .map(match => ({ name: `${path}:${content.slice(0, match.index).split('\n').length}`, block: match[1] })); +} + +function runWithBranchRef(block: string) { + const root = mkdtempSync(resolve(ROOT, '.squad-sha-guard-')); + try { + for (const path of BRANCH_REF_CLEANUP) { + mkdirSync(dirname(resolve(root, path)), { recursive: true }); + writeFileSync(resolve(root, path), `${path}\n`); + } + // The contents API accepts a branch as `ref`, so only the block's SHA guard can stop it. + const script = ` +exec 3>&2 +gh() { + printf 'CALL gh %s\\n' "$*" >&3 + case "$*" in + "aw --version") printf 'gh aw version v0.91.5\\n' ;; + "repo view --json nameWithOwner --jq .nameWithOwner") printf 'octo/example\\n' ;; + "repo view --json defaultBranchRef --jq .defaultBranchRef.name") printf 'main\\n' ;; + *"--jq .has_issues") printf 'true\\n' ;; + *"${MANIFEST_API}"*) printf '%s\\n' "${PACKAGE_NAME}" ;; + esac + return 0 +} +git() { printf 'CALL git %s\\n' "$*" >&3; } +node() { printf 'CALL node %s\\n' "$*" >&3; } +SQUAD_SHA="${BRANCH_REF}" +${block.replace(/^SQUAD_SHA="<40-character-commit-sha>"/m, `SQUAD_SHA="${BRANCH_REF}"`)} +echo "MANIFEST BLOCK COMPLETED" +`; + const result = spawnSync('bash', ['-c', script], { + cwd: root, + encoding: 'utf8', + // Any git call that escapes the stub fails instead of touching this repository. + env: { ...process.env, GIT_DIR: resolve(root, 'no-git-dir') }, + }); + return { + ...result, + calls: result.stderr.split('\n').filter(line => line.startsWith('CALL ')), + remaining: BRANCH_REF_CLEANUP.filter(path => existsSync(resolve(root, path))), + }; + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +describe('SQUAD_SHA guard before every manifest identity check', () => { + it('finds every runnable block that reads the manifest through the contents API', () => { + expect(Object.fromEntries(MANIFEST_BLOCK_SURFACES.map(path => [path, manifestBlocks(path).length]))).toEqual({ + ...Object.fromEntries([CANONICAL, ...MIRRORS].map(path => [path, 3])), + [GUIDE]: 3, + 'README.md': 1, + }); + }); + + it.each(MANIFEST_BLOCK_SURFACES.flatMap(manifestBlocks).map(({ name, block }) => [name, block]))( + '%s stops on a branch name before the contents API, install, or cleanup', + (name, block) => { + const result = runWithBranchRef(block); + const input = `${name} with SQUAD_SHA=${BRANCH_REF}`; + expect({ + contentsApiCalls: result.calls.filter(call => call.includes(MANIFEST_API)), + installCalls: result.calls.filter(call => call.startsWith('CALL gh aw add')), + remaining: result.remaining, + status: result.status, + }, `${input} must stop before the contents API, install, or cleanup:\n${result.stderr}`).toEqual({ + contentsApiCalls: [], + installCalls: [], + remaining: BRANCH_REF_CLEANUP, + status: 1, + }); + expect(result.stderr, `${input} must say why it stopped`).toMatch(/^STOP: SQUAD_SHA must be /m); + expect(result.stdout, input).not.toContain('MANIFEST BLOCK COMPLETED'); + }, + ); +}); + describe('gh-aw legacy-identity migration with committed legacy locks', () => { let outputs: { approved: string; upgraded: string; committed: string }; beforeAll(() => { diff --git a/test/gh-aw-install-staging.test.ts b/test/gh-aw-install-staging.test.ts index a4cfb3c16..2c86e3226 100644 --- a/test/gh-aw-install-staging.test.ts +++ b/test/gh-aw-install-staging.test.ts @@ -84,8 +84,9 @@ function printedCommands(stderr: string, heading: string): string { return lines.slice(start + 1, end < 0 ? undefined : end).map(line => line.slice(2)).join('\n'); } -function runPrintedCommands(root: string, commands: string, addStatus: number) { +function runPrintedCommands(root: string, commands: string, addStatus: number, materialize = false) { // No `set -e`: pasted commands keep running after a failure unless they stop themselves. + // With `materialize`, the printed --materialize-runtime command runs the consumer's real verifier. const script = ` exec 3>&2 gh() { @@ -93,11 +94,18 @@ gh() { if [ "$1 $2" = "aw add" ]; then return "$ADD_STATUS"; fi return 0 } -node() { printf 'CALL node %s\\n' "$*" >&3; } +node() { + printf 'CALL node %s\\n' "$*" >&3 + if [ "$MATERIALIZE" = "1" ] && [ "$*" = ".github/workflows/shared/squad-install-verifier.mjs --materialize-runtime" ]; then + command node "$@" + fi +} ${commands} `; const result = spawnSync('bash', ['-c', script], { - cwd: root, encoding: 'utf8', env: { ...process.env, ADD_STATUS: String(addStatus) }, + cwd: root, + encoding: 'utf8', + env: { ...process.env, ADD_STATUS: String(addStatus), MATERIALIZE: materialize ? '1' : '0' }, }); return result.stderr.split('\n').filter(line => line.startsWith('CALL ')); } @@ -263,6 +271,7 @@ describe('gh-aw: verified ownership staging under consumer ignore rules', () => ` gh aw add github/squad/workflows@${revision} --force &&`, ' rm -f .github/aw/packages/bradygaster-squad-workflows-3632054824e8.json &&', ` rm -f ${UNOWNED_MUTABLE_ROUTER_SKILL} &&`, + ' node .github/workflows/shared/squad-install-verifier.mjs --materialize-runtime &&', ' gh aw compile --strict &&', ' node .github/workflows/shared/squad-install-verifier.mjs --verify-install --strict-compile', ].join('\n')); @@ -382,7 +391,8 @@ describe('gh-aw: verified ownership staging under consumer ignore rules', () => it.each([ [1, [LEGACY_OWNERSHIP, UNOWNED_MUTABLE_ROUTER_SKILL], []], - [0, [], ['CALL gh aw compile --strict', + [0, [], ['CALL node .github/workflows/shared/squad-install-verifier.mjs --materialize-runtime', + 'CALL gh aw compile --strict', 'CALL node .github/workflows/shared/squad-install-verifier.mjs --verify-install --strict-compile']], ] as const)('runs the printed recovery cleanup only after the reinstall succeeds (gh aw add exits %i)', (addStatus, remaining, afterReinstall) => { @@ -402,6 +412,24 @@ describe('gh-aw: verified ownership staging under consumer ignore rules', () => expect(calls, input).toEqual([`CALL gh aw add ${PACKAGE_NAME}@${revision} --force`, ...afterReinstall]); }); + it.each([ + ['stale', (root: string, path: string) => write(root, path, 'stale materialized runtime\n')], + ['missing', (root: string, path: string) => rmSync(join(root, path))], + ] as const)('repairs a %s materialized runtime file with the printed recovery commands', (state, damage) => { + // gh aw add rewrites only the package-owned copy under .github/aw/squad/runtime/. + const path = '.github/workflows/shared/squad.md'; + const root = consumer(); + damage(root, path); + const failed = spawnSync(process.execPath, [verifier, '--verify-install', '--source-revision', revision], { + cwd: root, encoding: 'utf8', + }); + expect(failed.status, `${state} ${path}: ${failed.stderr}`).toBe(1); + const commands = printedCommands(failed.stderr, 'Safe recovery'); + runPrintedCommands(root, commands, 0, true); + expect(verifyInstall(root, { expectedRevision: revision }).failures, + `${state} ${path} after running the printed recovery commands:\n${commands}`).toEqual([]); + }, 120_000); + it.each([ [1, [LEGACY_OWNERSHIP]], [0, []], diff --git a/test/gh-aw-quality.test.ts b/test/gh-aw-quality.test.ts index 95d1bcb14..b53fedc98 100644 --- a/test/gh-aw-quality.test.ts +++ b/test/gh-aw-quality.test.ts @@ -1946,6 +1946,8 @@ describe('gh-aw: compiled workflow shell input security contract', () => { expect(install, 'compiled squad.lock.yml must omit version to use the pinned action latest default') .not.toMatch(/^\s+version:/m); expect(install).toContain('skip-init: "true"'); + expect(install, 'compiled squad.lock.yml must download releases from github/squad, not the pinned action default') + .toMatch(/^\s+repository: "?github\/squad"?$/m); expect(compiled, 'compiled squad.lock.yml must not contain the obsolete release resolver') .not.toContain('squad-release'); expect(compiled).not.toContain('vars.SQUAD_CLI_VERSION'); @@ -4857,6 +4859,30 @@ describe('gh-aw: canonical package integrity contract', () => { .not.toContain('exactly as typed'); }); + it('rejects a case-variant ownership record left beside the exact record', () => { + // gh-aw names each record by a digest of the identity as typed, so reinstalling a + // GitHub/Squad/workflows install as github/squad/workflows leaves both records. + const variant = 'GitHub/Squad/workflows'; + const variantPath = '.github/aw/packages/github-squad-workflows-' + + `${createHash('sha256').update(variant).digest('hex').slice(0, 12)}.json`; + const root = makeConsumer(); + const record = JSON.parse(readText(join(root, OWNERSHIP_FIXTURE))); + writeFileSync(join(root, variantPath), `${JSON.stringify({ + ...record, package: variant, source: `${variant}@${revisionA}`, + }, null, 2)}\n`); + const state = `${OWNERSHIP_FIXTURE} (${PACKAGE_NAME}) beside ${variantPath} (${variant})`; + const failure = verifyInstall(root, { expectedRevision: revisionA }).failures.join('\n'); + expect(failure, `${state} must fail verification`).toContain( + `Expected exactly one Squad package ownership record; found ${OWNERSHIP_FIXTURE} (${PACKAGE_NAME}) ` + + `and case-variant record ${variantPath} (${variant}), so more than one record claims the installed files.`, + ); + expect(failure, `${state} must name the record to delete`) + .toContain(`After reinstalling as ${PACKAGE_NAME}, delete ${variantPath}.`); + unlinkSync(join(root, variantPath)); + expect(verifyInstall(root, { expectedRevision: revisionA }).failures, `${state} after deleting ${variantPath}`) + .toEqual([]); + }); + it('rejects unsafe standalone contract shapes without filesystem access', () => { const contract = JSON.parse(readFileSync(join(process.cwd(), CONTRACT_SOURCE), 'utf8')); contract.skills[0].destination = '../outside'; diff --git a/test/squad-cli-latest.test.ts b/test/squad-cli-latest.test.ts index 5f228fee5..e85dcbf28 100644 --- a/test/squad-cli-latest.test.ts +++ b/test/squad-cli-latest.test.ts @@ -17,6 +17,16 @@ const actionPath = '.github/actions/squad-init/action.yml'; const installerPath = 'scripts/install.sh'; const action = parse(approved.files[actionPath].content); +/** A squad-init input as the runner resolves it for the activation step: its `with:` value, else the action default. */ +function activationInput(name: string): string { + const frontmatter = parse(source.slice('---\n'.length, source.indexOf('\n---\n', '---\n'.length))); + const steps = frontmatter.jobs.activation.steps.filter( + (step: { uses?: string }) => step.uses === `github/squad/.github/actions/squad-init@${revision}`, + ); + expect(steps, `workflows/shared/squad.md activation steps using squad-init@${revision}`).toHaveLength(1); + return String(steps[0].with?.[name] ?? action.inputs[name].default); +} + function pinnedInstallShell(platform: NodeJS.Platform = process.platform): string { const install = action.runs.steps.find((step: { id?: string }) => step.id === 'install'); expect(install).toBeDefined(); @@ -43,11 +53,11 @@ set -eu url="$2" printf '%s\\n' "$url" >> "$PWD/fetches" case "$url" in - https://api.github.com/repos/bradygaster/squad/releases/latest) + https://api.github.com/repos/github/squad/releases/latest) printf '{"tag_name":"v1.0.0"}\\n';; - https://github.com/bradygaster/squad/releases/download/v1.0.0/squad-linux-x64.tar.gz) + https://github.com/github/squad/releases/download/v1.0.0/squad-linux-x64.tar.gz) printf 'release archive' > "$4";; - https://github.com/bradygaster/squad/releases/download/v1.0.0/SHA256SUMS.txt) + https://github.com/github/squad/releases/download/v1.0.0/SHA256SUMS.txt) case "$TEST_MODE" in download-failure) exit 22;; missing) printf '${digest} squad-darwin-arm64.tar.gz\\n' > "$4";; @@ -81,8 +91,8 @@ ${install.run} cwd: scratch, env: { ...process.env, - INPUT_VERSION: action.inputs.version.default, - INPUT_REPO: action.inputs.repository.default, + INPUT_VERSION: activationInput('version'), + INPUT_REPO: activationInput('repository'), TEST_MODE: mode, }, encoding: 'utf8', @@ -154,17 +164,29 @@ describe('Squad standalone activation latest-release default', () => { .toContain('installer="${repo_root}/scripts/install.sh"'); }); + it('passes repository explicitly because the pinned action still defaults to the superseded owner', () => { + expect(action.inputs.repository.default, `squad-init@${revision} repository default`).toBe('bradygaster/squad'); + expect( + activationInput('repository'), + `workflows/shared/squad.md must pass repository to squad-init@${revision}, whose default is bradygaster/squad`, + ).toBe('github/squad'); + }); + it('executes the pinned action latest default with a verified platform archive', () => { const result = runPinnedInstall('valid'); + expect( + result.fetches.trim().split('\n'), + `squad-init@${revision} downloads with workflows/shared/squad.md inputs ` + + `(repository=${activationInput('repository')}, version=${activationInput('version')})`, + ).toEqual([ + 'https://api.github.com/repos/github/squad/releases/latest', + 'https://github.com/github/squad/releases/download/v1.0.0/squad-linux-x64.tar.gz', + 'https://github.com/github/squad/releases/download/v1.0.0/SHA256SUMS.txt', + ]); expect(result.status, result.output).toBe(0); expect(result.output).toContain('Checksum verified'); expect(result.extracted).toBe(true); expect(result.outputs).toContain('version=1.0.0'); - expect(result.fetches.trim().split('\n')).toEqual([ - 'https://api.github.com/repos/bradygaster/squad/releases/latest', - 'https://github.com/bradygaster/squad/releases/download/v1.0.0/squad-linux-x64.tar.gz', - 'https://github.com/bradygaster/squad/releases/download/v1.0.0/SHA256SUMS.txt', - ]); }, 65_000); it('reports premature action failure before reading the download trace', () => { diff --git a/workflows/package/squad-bootstrap.md b/workflows/package/squad-bootstrap.md index 580c67ee9..23ef32e9b 100644 --- a/workflows/package/squad-bootstrap.md +++ b/workflows/package/squad-bootstrap.md @@ -153,7 +153,7 @@ pre-agent-steps: node --check "$path" >/dev/null } # BEGIN GENERATED RESOURCE DIGESTS - check_hash "$install_verifier" "0f5cadf31eda9d059795a2356ed1a42446912de9ce0401b023888cc3e6d86edd" + check_hash "$install_verifier" "c3416f68efee474790165d36e5dd9903430753cf1d710868dad0ee0834e5146b" check_hash "$cast_validator" "15f0d47917d269b5b967dc2a782a0eb761c39af7a92ddb64fed687a427070760" check_hash "$bootstrap_validator" "d552d041f006249bfaa67b10a75877f813796b7698c718ac7eb7cf16f3a72e31" # END GENERATED RESOURCE DIGESTS diff --git a/workflows/package/squad-deps-worker.md b/workflows/package/squad-deps-worker.md index fe3dd2d92..17d9d7968 100644 --- a/workflows/package/squad-deps-worker.md +++ b/workflows/package/squad-deps-worker.md @@ -649,6 +649,7 @@ jobs: uses: github/squad/.github/actions/squad-init@5c662ba015ec4f99befa51b0e8ca4f2148b0497f with: skip-init: "true" + repository: github/squad - name: Initialize Squad team env: GH_TOKEN: ${{ steps.squad-app-token.outputs.token || secrets.SQUAD_GITHUB_TOKEN || github.token }} diff --git a/workflows/package/squad-implement-worker.md b/workflows/package/squad-implement-worker.md index 7051318f9..99d8833ad 100644 --- a/workflows/package/squad-implement-worker.md +++ b/workflows/package/squad-implement-worker.md @@ -784,6 +784,7 @@ jobs: uses: github/squad/.github/actions/squad-init@5c662ba015ec4f99befa51b0e8ca4f2148b0497f with: skip-init: "true" + repository: github/squad - name: Initialize Squad team env: GH_TOKEN: ${{ steps.squad-app-token.outputs.token || secrets.SQUAD_GITHUB_TOKEN || github.token }} diff --git a/workflows/package/squad-retro.md b/workflows/package/squad-retro.md index fb7392015..c774e747d 100644 --- a/workflows/package/squad-retro.md +++ b/workflows/package/squad-retro.md @@ -769,6 +769,7 @@ jobs: uses: github/squad/.github/actions/squad-init@5c662ba015ec4f99befa51b0e8ca4f2148b0497f with: skip-init: "true" + repository: github/squad - name: Initialize Squad team env: GH_TOKEN: ${{ steps.squad-app-token.outputs.token || secrets.SQUAD_GITHUB_TOKEN || github.token }} diff --git a/workflows/package/squad.md b/workflows/package/squad.md index 9a2018aa4..2652eeb4f 100644 --- a/workflows/package/squad.md +++ b/workflows/package/squad.md @@ -1002,6 +1002,7 @@ jobs: uses: github/squad/.github/actions/squad-init@5c662ba015ec4f99befa51b0e8ca4f2148b0497f with: skip-init: "true" + repository: github/squad - name: Initialize Squad team env: GH_TOKEN: ${{ steps.squad-app-token.outputs.token || secrets.SQUAD_GITHUB_TOKEN || github.token }} diff --git a/workflows/shared/squad-install-verifier.mjs b/workflows/shared/squad-install-verifier.mjs index b3b33a20b..ab851a441 100644 --- a/workflows/shared/squad-install-verifier.mjs +++ b/workflows/shared/squad-install-verifier.mjs @@ -779,20 +779,32 @@ function findOwnershipRecord(root) { matches.push({ path: relativePath, record }); } else if (typeof record?.package === 'string' && ACCEPTED_PACKAGE_NAMES .some(accepted => accepted.toLowerCase() === record.package.toLowerCase())) { - caseVariants.push(`${relativePath} (${record.package})`); + caseVariants.push({ path: relativePath, record }); } } + const describeRecords = records => records.map(({ path, record }) => `${path} (${record.package})`).join(', '); if (matches.length > 1) { throw new Error( `Expected exactly one Squad package ownership record; found ${matches.length}: ` - + `${matches.map(({ path, record }) => `${path} (${record.package})`).join(', ')}. ` + + `${describeRecords(matches)}. ` + 'Keep only the record whose identity matches the installed workflow source bindings; ' + `after reinstalling as ${PACKAGE_NAME}, delete ${LEGACY_OWNERSHIP_DESTINATIONS.join(', ')}.`, ); } + if (matches.length === 1 && caseVariants.length > 0) { + // gh-aw keys records by the identity exactly as typed, so a case-only reinstall keeps the old record. + const [{ record }] = matches; + throw new Error( + `Expected exactly one Squad package ownership record; found ${describeRecords(matches)} and case-variant ` + + `${caseVariants.length === 1 ? 'record' : 'records'} ${describeRecords(caseVariants)}, ` + + 'so more than one record claims the installed files. gh-aw records the package identity exactly as typed ' + + `and Squad matches it case-sensitively. After reinstalling as ${record.package}, delete ` + + `${caseVariants.map(({ path }) => path).join(', ')}.`, + ); + } if (matches.length !== 1) { // Identity matching stays exact; this only explains a case-only mismatch. - const hint = caseVariants.length === 0 ? '' : ` Found ${caseVariants.join(', ')}: gh-aw records the ` + const hint = caseVariants.length === 0 ? '' : ` Found ${describeRecords(caseVariants)}: gh-aw records the ` + 'package identity exactly as typed and Squad matches it case-sensitively. ' + `Reinstall as ${PACKAGE_NAME}, then delete that record.`; throw new Error( @@ -1240,6 +1252,7 @@ function recoveryMessage(revision) { `gh aw add ${PACKAGE_NAME}@${ref} --force`, ...LEGACY_OWNERSHIP_DESTINATIONS.map(path => `rm -f ${path}`), `rm -f ${UNOWNED_MUTABLE_ROUTER_SKILL}`, + 'node .github/workflows/shared/squad-install-verifier.mjs --materialize-runtime', 'gh aw compile --strict', 'node .github/workflows/shared/squad-install-verifier.mjs --verify-install --strict-compile', ]), diff --git a/workflows/shared/squad.md b/workflows/shared/squad.md index 7fb7efac1..6a0cbc50b 100644 --- a/workflows/shared/squad.md +++ b/workflows/shared/squad.md @@ -25,6 +25,8 @@ # The SHA-pinned squad-init action installs the latest stable GitHub Release # using its built-in default and mandatory checksum verification, without npm. # CLI release selection is independent of the immutable workflow/action pins. +# `repository` is explicit because that pinned action still defaults to the +# superseded bradygaster/squad path for its release downloads. # # Optional model: vars.SQUAD_MODEL; omit or use 'auto' for the engine default. # gh-aw resolves aliases with availability fallback. @@ -540,6 +542,7 @@ jobs: uses: github/squad/.github/actions/squad-init@5c662ba015ec4f99befa51b0e8ca4f2148b0497f with: skip-init: 'true' + repository: 'github/squad' - name: Initialize Squad team env: diff --git a/workflows/squad-bootstrap.md b/workflows/squad-bootstrap.md index 68780cd8d..abcc75f17 100644 --- a/workflows/squad-bootstrap.md +++ b/workflows/squad-bootstrap.md @@ -160,7 +160,7 @@ pre-agent-steps: node --check "$path" >/dev/null } # BEGIN GENERATED RESOURCE DIGESTS - check_hash "$install_verifier" "0f5cadf31eda9d059795a2356ed1a42446912de9ce0401b023888cc3e6d86edd" + check_hash "$install_verifier" "c3416f68efee474790165d36e5dd9903430753cf1d710868dad0ee0834e5146b" check_hash "$cast_validator" "15f0d47917d269b5b967dc2a782a0eb761c39af7a92ddb64fed687a427070760" check_hash "$bootstrap_validator" "d552d041f006249bfaa67b10a75877f813796b7698c718ac7eb7cf16f3a72e31" # END GENERATED RESOURCE DIGESTS diff --git a/workflows/squad-workflows.manifest.json b/workflows/squad-workflows.manifest.json index da3ac93f5..fd09c64a1 100644 --- a/workflows/squad-workflows.manifest.json +++ b/workflows/squad-workflows.manifest.json @@ -15,18 +15,18 @@ "source": "workflows/package/squad.md", "destination": ".github/workflows/squad.md", "lock": ".github/workflows/squad.lock.yml", - "source_sha256": "5a15a269c36747553ce5099ebaddd90bc7d832d6855641a90368999a12f7941c", - "lock_sha256": "fc8e1c1b9006fddb70edef284b73ab4a5e52e34fb68c1e2cacf24087a9c8c570", - "package_lock_sha256": "791c6749c86c5f4d28912f40a789a0c62a2801c46a1cd230ef0a8098a7403ccf" + "source_sha256": "b3e9b33c8ddbf578e277d036c21ea5d1a5136cd234aa6b153627dc5f345bbf3d", + "lock_sha256": "36e56e20eedb07007d81a64ebcae38fffe7441c69ff0dbc792048ea9c5576e48", + "package_lock_sha256": "f744139e41ce8667555d8b6508b30391d4bf2a142bbefc85e418035c95c7dd97" }, { "name": "squad-implement-worker", "source": "workflows/package/squad-implement-worker.md", "destination": ".github/workflows/squad-implement-worker.md", "lock": ".github/workflows/squad-implement-worker.lock.yml", - "source_sha256": "c4b4b8cb9996613df8a0ac16d9438598b9c3ecb81759a83422dafe6063a3c5ef", - "lock_sha256": "38dbdd2e91401efc7b014318dfba13483a5db3e0f8e08989ad97459fe7fe51a1", - "package_lock_sha256": "865a3dbf0b9ff9bc9c80c74783c0a78a8f426d94103dd9723a7b213bc70331c3" + "source_sha256": "f95265bbca7cb7e5ffd2f0da8bf088186b268e9bf003877e5693566b42ebd0b8", + "lock_sha256": "a45525e04d183caee420286f45a192f86ec2d53e3cefd005669e9ab83fb09b18", + "package_lock_sha256": "4de77bd5b54f10ddc2ba5a90c96412feb042a99e3b20b52b5ab9fb9c046b5277" }, { "name": "squad-review", @@ -42,18 +42,18 @@ "source": "workflows/package/squad-deps-worker.md", "destination": ".github/workflows/squad-deps-worker.md", "lock": ".github/workflows/squad-deps-worker.lock.yml", - "source_sha256": "55618b94fbd7f5331b2ae146b3a09a824f38ae280ffdaa9f9fd5dddde0a99161", - "lock_sha256": "cff4edf52ef5056200d72a6cf7cd847982015a113a0b338c7a83cc33526c258b", - "package_lock_sha256": "208041ffbbfb2d278ddaa4d030b4a175d8d06cb176002c707b2edeb7801e6116" + "source_sha256": "f4a49fd47b8b945cbe9739908cd7d140ec54623e5259bde02a3d0780e4b2b0da", + "lock_sha256": "8a439e8847ccdf79fa2245b5f0cb3b17996a6af5d3d7a12c7261e881f0635317", + "package_lock_sha256": "bc87ff18c6d8ecfe837c45d33a0e333249d3d722042a69258b8e5300df38b595" }, { "name": "squad-retro", "source": "workflows/package/squad-retro.md", "destination": ".github/workflows/squad-retro.md", "lock": ".github/workflows/squad-retro.lock.yml", - "source_sha256": "fcf9ab360c98d838ae2150674a4e8ae2e11db427d5dfe8ed87ead4c29555121d", - "lock_sha256": "c0355db76ce43bd9e8274fd38dddafc2e9a0b24865e04f474823494593da2b27", - "package_lock_sha256": "17b5739c1ce46f5111eef8447413682756204e4c91fbd7cfccddf3c59e2683eb" + "source_sha256": "129a2ed4859d7b145bc1438ad42060e2452b9b400e67b3c9a6f1b34f06e40690", + "lock_sha256": "0eeb649e01dc97ca37992f5a6d9733ddce6700b6d3cd3cd2eb37eaf2966503ae", + "package_lock_sha256": "4735dd7a1ebc531e777d978b0bd344037fc5f2cefe58bc572a7a52b28f12dff6" }, { "name": "squad-improvement-worker", @@ -69,9 +69,9 @@ "source": "workflows/package/squad-bootstrap.md", "destination": ".github/workflows/squad-bootstrap.md", "lock": ".github/workflows/squad-bootstrap.lock.yml", - "source_sha256": "c2c1814a95da3219c32126b31efae9f5e660d6381f73bc6692076ea29a1e50bf", - "lock_sha256": "964c529371eb0e3348c5db99ac6c1ecbb00a0650c734f238cdaa6495a1ca325d", - "package_lock_sha256": "e5768109b59bd3fb58add777ef02fa964c2f9e24f5f006b232d63b81eba08324" + "source_sha256": "1759f8c06533572c2282bafd63a87d33a7c9fe5105738de7a785b5b42853cd5d", + "lock_sha256": "6859004e8d6e0f3a61fdf7398c77797f5c06ab0a431807e39bd4c36348d576e5", + "package_lock_sha256": "95256d66b3ad79ee13b800a251df09cdd6bfa20ea12d8ca1eda5ddaeaefc80d9" }, { "name": "squad-command-router", @@ -89,13 +89,13 @@ "workflows": [ { "name": "squad", - "lock_sha256": "fc8e1c1b9006fddb70edef284b73ab4a5e52e34fb68c1e2cacf24087a9c8c570", - "package_lock_sha256": "791c6749c86c5f4d28912f40a789a0c62a2801c46a1cd230ef0a8098a7403ccf" + "lock_sha256": "36e56e20eedb07007d81a64ebcae38fffe7441c69ff0dbc792048ea9c5576e48", + "package_lock_sha256": "f744139e41ce8667555d8b6508b30391d4bf2a142bbefc85e418035c95c7dd97" }, { "name": "squad-implement-worker", - "lock_sha256": "8c44adcffa429dba8a7cc30cddde635d37e16e873383b10a75ff12235795ee7b", - "package_lock_sha256": "0ed8eb4a911b3038a066af43803f703cceab207cb53ef7358c0d256e7306eea5" + "lock_sha256": "83d3a3294e624f82c6581e32805a6e78cd9499c46ff9783e1b2935b625fcec92", + "package_lock_sha256": "469fa5f99f2de9321ec92f4dcf36968a80256ad97a65f6ed77affb9e47709471" }, { "name": "squad-review", @@ -104,13 +104,13 @@ }, { "name": "squad-deps-worker", - "lock_sha256": "4bb64b46474b771d83df98c52c9b3e65e1485b4446e5c1d499cd9f091cff8121", - "package_lock_sha256": "4403fe96b0e9ce5340fa69abe977f5079216fae718005f0f0f16a3cb3779f717" + "lock_sha256": "ff258df99cc1c92ab632c7a192a174f8125f294bd976c26b15ea91468e585f2d", + "package_lock_sha256": "9e6ee36411a4959fecf966dfae794fde2fbeea9fd7bbdb6a6c6173517db1f1f2" }, { "name": "squad-retro", - "lock_sha256": "2f22310121449fe4f2d927639e48c34abd84031628209e7e1e215411e02013b9", - "package_lock_sha256": "2604a9a303e8d85787a7c5a95f69b7cdc265261131de7fc16bd960aa85823d09" + "lock_sha256": "f7f8297c4e7f832d3ebf316c3163422609eb567876a822e7799524bb18d7a864", + "package_lock_sha256": "b04edb7bb9b921e3309f949650c41160975e082c55ebfb04ed94bb611b7647ce" }, { "name": "squad-improvement-worker", @@ -119,8 +119,8 @@ }, { "name": "squad-bootstrap", - "lock_sha256": "82d11d6bd55847a642251545b9721d454b63d507ec509a7aac54987d403df82d", - "package_lock_sha256": "ecf0740305ad948948273f302eeca0ae4f6cacd6017f71457991265c56816cc8" + "lock_sha256": "c6e6987f079d5fbfcee27020280210310630c03d599a9bbd566497e1e3b5292c", + "package_lock_sha256": "1eab03ef2d65d94118f3c9f171e8118299bee422bca9f2a70358e1c56da85894" }, { "name": "squad-command-router", @@ -169,7 +169,7 @@ "package_destination": ".github/workflows/shared/squad-install-verifier.mjs", "destination": ".github/workflows/shared/squad-install-verifier.mjs", "ownership": "manifest", - "sha256": "0f5cadf31eda9d059795a2356ed1a42446912de9ce0401b023888cc3e6d86edd" + "sha256": "c3416f68efee474790165d36e5dd9903430753cf1d710868dad0ee0834e5146b" }, { "path": "shared/squad-command-contract.mjs", @@ -241,7 +241,7 @@ "package_destination": ".github/aw/squad/runtime/shared/squad.md", "destination": ".github/workflows/shared/squad.md", "ownership": "manifest", - "sha256": "5969bc480ab559346f4a379bb82d54288628c9ecdbcb11b6bae573ff45db32b3" + "sha256": "cc93d6596b0a88ad916397e1f1481afd16c625a85d38419d00ee44d6dbece031" }, { "path": "shared/squad-planning-ontology.md", From d7dd834c1fe1de3363b3a431a40968731d60d53f Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Mon, 9 Nov 2026 01:53:58 +0200 Subject: [PATCH 4/6] fix(gh-aw): drop legacy install fallback, verify staged case variants Revisions that declare only bradygaster/squad/workflows install the Squad CLI through squad-init without a repository input, so activation downloads the CLI and its checksum from bradygaster/squad. That repository is now a separately controlled fork with no releases. Stop sending users there: - The identity-gate STOP hint in the skill and its four mirrors, the guide and the README now says to use a revision whose manifest declares github/squad/workflows, or to ask the Squad maintainers for one, and says why older revisions are not an option. - Remove the instructions to install an older revision under the legacy identity or stay on one from the skill, the guide, the README, .github/agents.md and workflows/README.md, and the step-4 and anti-pattern allowance for the legacy squad-init action name outside the migration. - The verifier still accepts existing bradygaster/squad/workflows installs through its exact allowlist, and the documented legacy-to-canonical upgrade is unchanged. Its notice now says the legacy identity is accepted only so installations can move. --verify-staged-install also missed a case-variant ownership record, such as one for GitHub/Squad/workflows, that was deleted only from the working tree. The index still held it, so a clean checkout of the commit failed --verify-install. The staged check now lists .github/aw/packages/ one level deep and rejects any staged record whose package is a case variant of an accepted identity, naming its path. The step-7 deletion gate in the skill, its mirrors and both guide gates admits exactly that deletion: a .github/aw/packages/*.json record whose package at HEAD is a case variant. Regenerate the integrity manifest and bootstrap digests for the verifier and skill changes. Add a contract test that names file:line for any install hint or STOP message that falls back to the legacy identity, gate cases for the case-variant allowance, and a staged-verifier regression test that also verifies a clean checkout. Refs #2403 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../repo-move-github-squad-gh-aw-identity.md | 2 +- .github/agents.md | 7 +- .../skills/gh-aw-enlistment/SKILL.md | 67 +++-- README.md | 8 +- docs/src/content/docs/guide/gh-aw.md | 80 ++++-- .../skills/gh-aw-enlistment/SKILL.md | 67 +++-- .../skills/gh-aw-enlistment/SKILL.md | 67 +++-- templates/skills/gh-aw-enlistment/SKILL.md | 67 +++-- test/gh-aw-enlistment-skill.test.ts | 267 +++++++++++++++--- test/gh-aw-install-staging.test.ts | 50 +++- workflows/README.md | 11 +- workflows/package/squad-bootstrap.md | 2 +- workflows/shared/squad-install-verifier.mjs | 38 ++- workflows/skills/gh-aw-enlistment/SKILL.md | 67 +++-- workflows/squad-bootstrap.md | 2 +- workflows/squad-workflows.manifest.json | 14 +- 16 files changed, 590 insertions(+), 226 deletions(-) diff --git a/.changeset/repo-move-github-squad-gh-aw-identity.md b/.changeset/repo-move-github-squad-gh-aw-identity.md index c84550895..738a1c297 100644 --- a/.changeset/repo-move-github-squad-gh-aw-identity.md +++ b/.changeset/repo-move-github-squad-gh-aw-identity.md @@ -3,4 +3,4 @@ '@bradygaster/squad-sdk': patch --- -Update the shipped gh-aw-enlistment skill for the repository's new home, `github/squad`. It now installs the gh-aw package as `github/squad/workflows`, links to the docs guide's new address, and allowlists the `github/squad/.github/actions/squad-init` action. Before installing or upgrading, it checks that the chosen Squad revision declares `github/squad/workflows` in its package manifest, and stops otherwise. It also explains how to move an installation off the legacy `bradygaster/squad/workflows` identity, which the install verifier still accepts. That upgrade stops before removing anything if the forced reinstall fails, and until it is committed, its strict compile gate accepts only the four expected squad-init owner-replacement warnings. +Update the shipped gh-aw-enlistment skill for the repository's new home, `github/squad`. It now installs the gh-aw package as `github/squad/workflows`, links to the docs guide's new address, and allowlists the `github/squad/.github/actions/squad-init` action. Before installing or upgrading, it checks that the chosen Squad revision declares `github/squad/workflows` in its package manifest, and stops otherwise. It no longer suggests installing an older revision as `bradygaster/squad/workflows`, because such revisions download the Squad CLI from the separately controlled `bradygaster/squad` fork. It also explains how to move an installation off that legacy identity, which the install verifier accepts only so that existing installations can move. That upgrade stops before removing anything if the forced reinstall fails, and until it is committed, its strict compile gate accepts only the four expected squad-init owner-replacement warnings. Its staging gate also permits deleting a case-variant ownership record, such as one left by an install typed as `GitHub/Squad/workflows`. diff --git a/.github/agents.md b/.github/agents.md index ab61d4e10..e2af3fdc6 100644 --- a/.github/agents.md +++ b/.github/agents.md @@ -56,8 +56,11 @@ rm -f .github/skills/agentic-workflows/SKILL.md `github/squad/workflows` verifies only at Squad revisions whose `workflows/squad-workflows.manifest.json` declares `"package": "github/squad/workflows"`; the quick start's identity check stops -on any other revision. Install an older maintainer-approved revision as -`bradygaster/squad/workflows`, or ask the Squad maintainers for a newer revision. +on any other revision. Do not install an older revision as +`bradygaster/squad/workflows` instead: its activation downloads the Squad CLI +from the separately controlled `bradygaster/squad` fork, which has no releases. +Use a revision whose manifest declares `github/squad/workflows`, or ask the +Squad maintainers for one. The removal is intentional: gh-aw v0.91.5 generates that generic tool-owned router, which loads mutable prompts from the current `github/gh-aw` repository. diff --git a/.squad-templates/skills/gh-aw-enlistment/SKILL.md b/.squad-templates/skills/gh-aw-enlistment/SKILL.md index 9839df422..c69782bac 100644 --- a/.squad-templates/skills/gh-aw-enlistment/SKILL.md +++ b/.squad-templates/skills/gh-aw-enlistment/SKILL.md @@ -203,7 +203,7 @@ test "${squad_package}" = "github/squad/workflows" || { echo "Check gh auth status --hostname github.com and network access, and confirm SQUAD_SHA is a github/squad commit that has this manifest, then retry." >&2 else echo "STOP: Squad ${SQUAD_SHA} does not declare the github/squad/workflows package (found '${squad_package}')." >&2 - echo "Install a revision that declares bradygaster/squad/workflows under that identity, or ask the Squad maintainers for a newer revision." >&2 + echo "Use a revision whose manifest declares github/squad/workflows, or ask the Squad maintainers for one. Older revisions download the Squad CLI from the bradygaster/squad fork, which has no releases." >&2 fi exit 1 } @@ -215,11 +215,11 @@ rm -f .github/skills/agentic-workflows/SKILL.md `github/squad/workflows` verifies only at Squad revisions whose `workflows/squad-workflows.manifest.json` declares `"package": "github/squad/workflows"`. The identity check above reads that -manifest at `SQUAD_SHA` and stops on any other revision. An older -maintainer-approved revision declares `bradygaster/squad/workflows`: install it -under that identity, because its verifier accepts no other, or ask the Squad -maintainers for a newer revision. Such a revision's safe-update report names -the same squad-init action as `bradygaster/squad/.github/actions/squad-init`. +manifest at `SQUAD_SHA` and stops on any other revision. Do not install an +older revision under `bradygaster/squad/workflows` instead: its activation +downloads the Squad CLI from the separately controlled `bradygaster/squad` +fork, which has no releases. Use a revision whose manifest declares +`github/squad/workflows`, or ask the Squad maintainers for one. The nested `workflows/aw.yml` is the only supported distribution registration. It isolates package auto-discovery from unrelated repository skills and agents, @@ -255,9 +255,10 @@ Revisions whose `workflows/squad-workflows.manifest.json` declares `bradygaster/squad/workflows` identity. Earlier revisions, including ones committed after the repository moved to `github/squad`, declare and verify only `bradygaster/squad/workflows`, and installations made from them carry it in -their ownership record, workflow `source:` lines, and locks. Upgrade such an -installation to a revision that declares `github/squad/workflows` with the block -below. gh-aw keeps the superseded +their ownership record, workflow `source:` lines, and locks. Those revisions +download the Squad CLI from the separately controlled `bradygaster/squad` fork, +which has no releases, so move such an installation to a revision that declares +`github/squad/workflows` with the block below. gh-aw keeps the superseded `.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json` after the forced reinstall, so the block deletes exactly that file before verifying; the verifier fails closed while both ownership records exist. If the forced @@ -268,13 +269,10 @@ also makes `gh aw add` report one safe-update change: action squad-init action under the repository's new owner; list it in the PR's security review note. **STOP** on any other new secret or action. Until the upgrade is committed, every strict compile repeats that change as four -warnings, which step 5 allows only for this migration. Until an -installation moves, `--verify-install` still passes and prints a notice naming -the legacy identity. To stay on a revision that declares -`bradygaster/squad/workflows`, drop the identity check below, keep the -`gh aw add` line's STOP guard but replace its command with -`gh aw add "bradygaster/squad/workflows@${SQUAD_SHA}" --force`, and skip the -second `rm -f`: that ownership record is the only one such an installation has. +warnings, which step 5 allows only for this migration. Verifiers of revisions +that declare `github/squad/workflows` still accept the legacy identity, so an +existing installation keeps verifying while it moves, and they print a notice +that says to move it. ```bash : "${SQUAD_SHA:?STOP: set SQUAD_SHA to an explicit, maintainer-approved 40-character Squad commit SHA before upgrading.}" @@ -291,7 +289,7 @@ test "${squad_package}" = "github/squad/workflows" || { echo "Check gh auth status --hostname github.com and network access, and confirm SQUAD_SHA is a github/squad commit that has this manifest, then retry." >&2 else echo "STOP: Squad ${SQUAD_SHA} does not declare the github/squad/workflows package (found '${squad_package}')." >&2 - echo "Install a revision that declares bradygaster/squad/workflows under that identity, or ask the Squad maintainers for a newer revision." >&2 + echo "Use a revision whose manifest declares github/squad/workflows, or ask the Squad maintainers for one. Older revisions download the Squad CLI from the bradygaster/squad fork, which has no releases." >&2 fi exit 1 } @@ -335,10 +333,6 @@ On a clean repo, `gh aw add` reports these expected safe updates and **nothing e - Action: **`github/squad/.github/actions/squad-init`** -At a revision whose manifest declares `bradygaster/squad/workflows`, the same -action is reported as `bradygaster/squad/.github/actions/squad-init`. Accept -that name only for such a revision. - > **These are referenced names, not prerequisites.** `gh aw add` lists the secrets > the workflows *reference* so you can approve that surface — it is not asking you > to supply them. Both secrets are optional, they need not exist, and neither is @@ -476,7 +470,23 @@ git diff --cached --stat unexpected_deletions="$( git diff --cached --diff-filter=D --name-only | grep -vxF -e '.github/skills/agentic-workflows/SKILL.md' \ - -e '.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json' || true + -e '.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json' | + while IFS= read -r path; do + # Also allowed: an ownership record whose package is a case variant, such as GitHub/Squad/workflows. + if [[ "${path}" =~ ^\.github/aw/packages/[^/]+\.json$ ]] && + git show "HEAD:${path}" | node -e ' + const accepted = ["github/squad/workflows", "bradygaster/squad/workflows"]; + let text = ""; + process.stdin.on("data", chunk => { text += chunk; }).on("end", () => { + let name; + try { name = JSON.parse(text).package; } catch {} + process.exit(typeof name === "string" && !accepted.includes(name) + && accepted.includes(name.toLowerCase()) ? 0 : 1); + });'; then + continue + fi + printf '%s\n' "${path}" + done || true )" test -z "${unexpected_deletions}" || { printf 'STOP: unexpected staged deletions:\n%s\n' "${unexpected_deletions}" >&2 @@ -486,9 +496,12 @@ test -z "${unexpected_deletions}" || { - The only permitted staged deletions are `.github/skills/agentic-workflows/SKILL.md`, when upgrading a repository that - previously committed gh-aw's mutable router, and + previously committed gh-aw's mutable router, `.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json`, when moving - an existing installation off the legacy `bradygaster/squad/workflows` identity. + an existing installation off the legacy `bradygaster/squad/workflows` identity, + and a `.github/aw/packages/*.json` ownership record whose `package` is a case + variant of `github/squad/workflows` or `bradygaster/squad/workflows`, such as + one left by an install typed as `GitHub/Squad/workflows`. - **STOP** if the staged diff shows any other **unexpected deletions**, **unexpected secrets**, edits to **unrelated files**, or committed **log/diagnostic output**. Re-scope with explicit `git add -- ` — never `git add .`, `git add -A`, or `git commit -a`. @@ -636,7 +649,7 @@ test "${squad_package}" = "github/squad/workflows" || { echo "Check gh auth status --hostname github.com and network access, and confirm SQUAD_SHA is a github/squad commit that has this manifest, then retry." >&2 else echo "STOP: Squad ${SQUAD_SHA} does not declare the github/squad/workflows package (found '${squad_package}')." >&2 - echo "Install a revision that declares bradygaster/squad/workflows under that identity, or ask the Squad maintainers for a newer revision." >&2 + echo "Use a revision whose manifest declares github/squad/workflows, or ask the Squad maintainers for one. Older revisions download the Squad CLI from the bradygaster/squad fork, which has no releases." >&2 fi exit 1 } @@ -708,9 +721,7 @@ gh pr merge --squash # auto-merge before human review. NEVER. `.github/skills/agentic-workflows/SKILL.md`; keep the exact Squad-owned `.github/skills/gh-aw-enlistment/SKILL.md`. - ❌ **Approving unknown safe updates.** Approve ONLY `SQUAD_GITHUB_APP_PRIVATE_KEY`, - `SQUAD_GITHUB_TOKEN`, and `github/squad/.github/actions/squad-init` (reported - as `bradygaster/squad/.github/actions/squad-init` only at a revision whose - manifest declares `bradygaster/squad/workflows`). Anything + `SQUAD_GITHUB_TOKEN`, and `github/squad/.github/actions/squad-init`. Anything else is a STOP. - ❌ **Treating `--approve` as the final compile.** Always finish with a plain `gh aw compile --strict` (no `--approve`). diff --git a/README.md b/README.md index 7a8c40ae1..e40acc9a3 100644 --- a/README.md +++ b/README.md @@ -613,7 +613,7 @@ test "${squad_package}" = "github/squad/workflows" || { echo "Check gh auth status --hostname github.com and network access, and confirm SQUAD_SHA is a github/squad commit that has this manifest, then retry." >&2 else echo "STOP: Squad ${SQUAD_SHA} does not declare the github/squad/workflows package (found '${squad_package}')." >&2 - echo "Install a revision that declares bradygaster/squad/workflows under that identity, or ask the Squad maintainers for a newer revision." >&2 + echo "Use a revision whose manifest declares github/squad/workflows, or ask the Squad maintainers for one. Older revisions download the Squad CLI from the bradygaster/squad fork, which has no releases." >&2 fi exit 1 } @@ -631,8 +631,10 @@ git push `github/squad/workflows` verifies only at Squad revisions whose `workflows/squad-workflows.manifest.json` declares `"package": "github/squad/workflows"`, so the identity check stops on any other -revision. Install an older maintainer-approved revision as -`bradygaster/squad/workflows`, or ask the Squad maintainers for a newer revision. +revision. Do not install an older revision as `bradygaster/squad/workflows` +instead: its activation downloads the Squad CLI from the separately controlled +`bradygaster/squad` fork, which has no releases. Use a revision whose manifest +declares `github/squad/workflows`, or ask the Squad maintainers for one. `gh aw add` compiles the workflows automatically. If it reports unapproved safe-update changes, review them and run `gh aw compile --approve`. diff --git a/docs/src/content/docs/guide/gh-aw.md b/docs/src/content/docs/guide/gh-aw.md index 45253e95d..7724072b6 100644 --- a/docs/src/content/docs/guide/gh-aw.md +++ b/docs/src/content/docs/guide/gh-aw.md @@ -122,7 +122,7 @@ test "${squad_package}" = "github/squad/workflows" || { echo "Check gh auth status --hostname github.com and network access, and confirm SQUAD_SHA is a github/squad commit that has this manifest, then retry." >&2 else echo "STOP: Squad ${SQUAD_SHA} does not declare the github/squad/workflows package (found '${squad_package}')." >&2 - echo "Install a revision that declares bradygaster/squad/workflows under that identity, or ask the Squad maintainers for a newer revision." >&2 + echo "Use a revision whose manifest declares github/squad/workflows, or ask the Squad maintainers for one. Older revisions download the Squad CLI from the bradygaster/squad fork, which has no releases." >&2 fi exit 1 } @@ -157,7 +157,23 @@ git diff --cached --stat unexpected_deletions="$( git diff --cached --diff-filter=D --name-only | grep -vxF -e '.github/skills/agentic-workflows/SKILL.md' \ - -e '.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json' || true + -e '.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json' | + while IFS= read -r path; do + # Also allowed: an ownership record whose package is a case variant, such as GitHub/Squad/workflows. + if [[ "${path}" =~ ^\.github/aw/packages/[^/]+\.json$ ]] && + git show "HEAD:${path}" | node -e ' + const accepted = ["github/squad/workflows", "bradygaster/squad/workflows"]; + let text = ""; + process.stdin.on("data", chunk => { text += chunk; }).on("end", () => { + let name; + try { name = JSON.parse(text).package; } catch {} + process.exit(typeof name === "string" && !accepted.includes(name) + && accepted.includes(name.toLowerCase()) ? 0 : 1); + });'; then + continue + fi + printf '%s\n' "${path}" + done || true )" test -z "${unexpected_deletions}" git commit -m "ci: add Squad agentic workflow" @@ -577,7 +593,7 @@ test "${squad_package}" = "github/squad/workflows" || { echo "Check gh auth status --hostname github.com and network access, and confirm SQUAD_SHA is a github/squad commit that has this manifest, then retry." >&2 else echo "STOP: Squad ${SQUAD_SHA} does not declare the github/squad/workflows package (found '${squad_package}')." >&2 - echo "Install a revision that declares bradygaster/squad/workflows under that identity, or ask the Squad maintainers for a newer revision." >&2 + echo "Use a revision whose manifest declares github/squad/workflows, or ask the Squad maintainers for one. Older revisions download the Squad CLI from the bradygaster/squad fork, which has no releases." >&2 fi exit 1 } @@ -588,10 +604,11 @@ rm -f .github/skills/agentic-workflows/SKILL.md `github/squad/workflows` verifies only at Squad revisions whose `workflows/squad-workflows.manifest.json` declares `"package": "github/squad/workflows"`, so the identity check reads that manifest -at `SQUAD_SHA` and stops on any other revision. An older maintainer-approved -revision declares `bradygaster/squad/workflows`: install it under that -identity, because its verifier accepts no other, or ask the Squad maintainers -for a newer revision. +at `SQUAD_SHA` and stops on any other revision. Do not install an older +revision under `bradygaster/squad/workflows` instead: its activation downloads +the Squad CLI from the separately controlled `bradygaster/squad` fork, which +has no releases. Use a revision whose manifest declares +`github/squad/workflows`, or ask the Squad maintainers for one. The nested `workflows/aw.yml` is the canonical package registration. Its package boundary prevents unrelated repository skills or agents from being @@ -682,9 +699,6 @@ On a clean repository, `gh aw add` reports these expected safe-update changes: - Restricted secrets: `SQUAD_GITHUB_APP_PRIVATE_KEY` and `SQUAD_GITHUB_TOKEN` - Action: `github/squad/.github/actions/squad-init` -At a revision whose manifest declares `bradygaster/squad/workflows`, the same -action is reported as `bradygaster/squad/.github/actions/squad-init`. - > **These are referenced names, not prerequisites.** `gh aw add` lists the secrets > the workflows *reference* so you can approve that surface — it is not asking you > to supply them. Both secrets are optional, they need not exist, and you do not @@ -801,7 +815,23 @@ git diff --cached --stat unexpected_deletions="$( git diff --cached --diff-filter=D --name-only | grep -vxF -e '.github/skills/agentic-workflows/SKILL.md' \ - -e '.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json' || true + -e '.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json' | + while IFS= read -r path; do + # Also allowed: an ownership record whose package is a case variant, such as GitHub/Squad/workflows. + if [[ "${path}" =~ ^\.github/aw/packages/[^/]+\.json$ ]] && + git show "HEAD:${path}" | node -e ' + const accepted = ["github/squad/workflows", "bradygaster/squad/workflows"]; + let text = ""; + process.stdin.on("data", chunk => { text += chunk; }).on("end", () => { + let name; + try { name = JSON.parse(text).package; } catch {} + process.exit(typeof name === "string" && !accepted.includes(name) + && accepted.includes(name.toLowerCase()) ? 0 : 1); + });'; then + continue + fi + printf '%s\n' "${path}" + done || true )" test -z "${unexpected_deletions}" git commit -m "ci: add Squad agentic workflow" @@ -826,10 +856,13 @@ the complete generated diff in the bootstrap PR, address Copilot review feedback, and wait for required checks. Merge only after human approval. The only permitted staged deletions are the exact router deletion above, for an -upgrade from a repository that previously committed it, and the superseded +upgrade from a repository that previously committed it, the superseded `.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json` ownership record, for an installation moving off the legacy `bradygaster/squad/workflows` -identity (see Upgrading). Any other deletion is a hard stop. +identity (see Upgrading), and a `.github/aw/packages/*.json` ownership record +whose `package` is a case variant of `github/squad/workflows` or +`bradygaster/squad/workflows`, such as one left by an install typed as +`GitHub/Squad/workflows`. Any other deletion is a hard stop. Consumer ignore rules such as `packages/` can silently omit the required `.github/aw/packages/` ownership JSON from directory staging. The staged verifier @@ -2601,7 +2634,7 @@ test "${squad_package}" = "github/squad/workflows" || { echo "Check gh auth status --hostname github.com and network access, and confirm SQUAD_SHA is a github/squad commit that has this manifest, then retry." >&2 else echo "STOP: Squad ${SQUAD_SHA} does not declare the github/squad/workflows package (found '${squad_package}')." >&2 - echo "Install a revision that declares bradygaster/squad/workflows under that identity, or ask the Squad maintainers for a newer revision." >&2 + echo "Use a revision whose manifest declares github/squad/workflows, or ask the Squad maintainers for one. Older revisions download the Squad CLI from the bradygaster/squad fork, which has no releases." >&2 fi exit 1 } @@ -2631,13 +2664,12 @@ Revisions whose `workflows/squad-workflows.manifest.json` declares `"package": "github/squad/workflows"` verify that identity and the legacy `bradygaster/squad/workflows` identity. Earlier revisions, including ones committed after the repository moved to `github/squad`, declare and verify only -`bradygaster/squad/workflows`, and installations made from them carry it. The -identity check above stops unless the target revision declares -`github/squad/workflows`. To stay on an earlier revision instead, drop the -identity check, keep the `gh aw add` line's STOP guard but replace its -command with `gh aw add "bradygaster/squad/workflows@${SQUAD_SHA}" --force`, -and skip the second `rm -f`: that ownership record is the only one such an -installation has. Otherwise ask the Squad maintainers for a newer revision. +`bradygaster/squad/workflows`, and installations made from them carry it. Those +revisions download the Squad CLI from the separately controlled +`bradygaster/squad` fork, which has no releases, so move such an installation +with the block above. The identity check above stops unless the target revision +declares `github/squad/workflows`; ask the Squad maintainers if you need such a +revision. Upgrading a legacy installation to a revision that declares `github/squad/workflows` installs `github/squad/workflows`. gh-aw keeps the @@ -2656,8 +2688,10 @@ four warnings; accept them only through the legacy-identity migration allowlist in [Strictly compile the installed workflows](#strictly-compile-the-installed-workflows). If your organization allows only selected actions, allow `github/squad/.github/actions/squad-init` (or actions created by GitHub) -before merging the upgrade. Until an installation moves, `--verify-install` -still passes and prints a notice naming the legacy identity. +before merging the upgrade. Verifiers of revisions that declare +`github/squad/workflows` still accept the legacy identity, so an existing +installation keeps verifying while it moves, and they print a notice that says +to move it. The schema-v2 integrity manifest records two complete compiled lock digests per workflow: `lock_sha256` for a per-workflow source annotation and diff --git a/packages/squad-cli/templates/skills/gh-aw-enlistment/SKILL.md b/packages/squad-cli/templates/skills/gh-aw-enlistment/SKILL.md index 9839df422..c69782bac 100644 --- a/packages/squad-cli/templates/skills/gh-aw-enlistment/SKILL.md +++ b/packages/squad-cli/templates/skills/gh-aw-enlistment/SKILL.md @@ -203,7 +203,7 @@ test "${squad_package}" = "github/squad/workflows" || { echo "Check gh auth status --hostname github.com and network access, and confirm SQUAD_SHA is a github/squad commit that has this manifest, then retry." >&2 else echo "STOP: Squad ${SQUAD_SHA} does not declare the github/squad/workflows package (found '${squad_package}')." >&2 - echo "Install a revision that declares bradygaster/squad/workflows under that identity, or ask the Squad maintainers for a newer revision." >&2 + echo "Use a revision whose manifest declares github/squad/workflows, or ask the Squad maintainers for one. Older revisions download the Squad CLI from the bradygaster/squad fork, which has no releases." >&2 fi exit 1 } @@ -215,11 +215,11 @@ rm -f .github/skills/agentic-workflows/SKILL.md `github/squad/workflows` verifies only at Squad revisions whose `workflows/squad-workflows.manifest.json` declares `"package": "github/squad/workflows"`. The identity check above reads that -manifest at `SQUAD_SHA` and stops on any other revision. An older -maintainer-approved revision declares `bradygaster/squad/workflows`: install it -under that identity, because its verifier accepts no other, or ask the Squad -maintainers for a newer revision. Such a revision's safe-update report names -the same squad-init action as `bradygaster/squad/.github/actions/squad-init`. +manifest at `SQUAD_SHA` and stops on any other revision. Do not install an +older revision under `bradygaster/squad/workflows` instead: its activation +downloads the Squad CLI from the separately controlled `bradygaster/squad` +fork, which has no releases. Use a revision whose manifest declares +`github/squad/workflows`, or ask the Squad maintainers for one. The nested `workflows/aw.yml` is the only supported distribution registration. It isolates package auto-discovery from unrelated repository skills and agents, @@ -255,9 +255,10 @@ Revisions whose `workflows/squad-workflows.manifest.json` declares `bradygaster/squad/workflows` identity. Earlier revisions, including ones committed after the repository moved to `github/squad`, declare and verify only `bradygaster/squad/workflows`, and installations made from them carry it in -their ownership record, workflow `source:` lines, and locks. Upgrade such an -installation to a revision that declares `github/squad/workflows` with the block -below. gh-aw keeps the superseded +their ownership record, workflow `source:` lines, and locks. Those revisions +download the Squad CLI from the separately controlled `bradygaster/squad` fork, +which has no releases, so move such an installation to a revision that declares +`github/squad/workflows` with the block below. gh-aw keeps the superseded `.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json` after the forced reinstall, so the block deletes exactly that file before verifying; the verifier fails closed while both ownership records exist. If the forced @@ -268,13 +269,10 @@ also makes `gh aw add` report one safe-update change: action squad-init action under the repository's new owner; list it in the PR's security review note. **STOP** on any other new secret or action. Until the upgrade is committed, every strict compile repeats that change as four -warnings, which step 5 allows only for this migration. Until an -installation moves, `--verify-install` still passes and prints a notice naming -the legacy identity. To stay on a revision that declares -`bradygaster/squad/workflows`, drop the identity check below, keep the -`gh aw add` line's STOP guard but replace its command with -`gh aw add "bradygaster/squad/workflows@${SQUAD_SHA}" --force`, and skip the -second `rm -f`: that ownership record is the only one such an installation has. +warnings, which step 5 allows only for this migration. Verifiers of revisions +that declare `github/squad/workflows` still accept the legacy identity, so an +existing installation keeps verifying while it moves, and they print a notice +that says to move it. ```bash : "${SQUAD_SHA:?STOP: set SQUAD_SHA to an explicit, maintainer-approved 40-character Squad commit SHA before upgrading.}" @@ -291,7 +289,7 @@ test "${squad_package}" = "github/squad/workflows" || { echo "Check gh auth status --hostname github.com and network access, and confirm SQUAD_SHA is a github/squad commit that has this manifest, then retry." >&2 else echo "STOP: Squad ${SQUAD_SHA} does not declare the github/squad/workflows package (found '${squad_package}')." >&2 - echo "Install a revision that declares bradygaster/squad/workflows under that identity, or ask the Squad maintainers for a newer revision." >&2 + echo "Use a revision whose manifest declares github/squad/workflows, or ask the Squad maintainers for one. Older revisions download the Squad CLI from the bradygaster/squad fork, which has no releases." >&2 fi exit 1 } @@ -335,10 +333,6 @@ On a clean repo, `gh aw add` reports these expected safe updates and **nothing e - Action: **`github/squad/.github/actions/squad-init`** -At a revision whose manifest declares `bradygaster/squad/workflows`, the same -action is reported as `bradygaster/squad/.github/actions/squad-init`. Accept -that name only for such a revision. - > **These are referenced names, not prerequisites.** `gh aw add` lists the secrets > the workflows *reference* so you can approve that surface — it is not asking you > to supply them. Both secrets are optional, they need not exist, and neither is @@ -476,7 +470,23 @@ git diff --cached --stat unexpected_deletions="$( git diff --cached --diff-filter=D --name-only | grep -vxF -e '.github/skills/agentic-workflows/SKILL.md' \ - -e '.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json' || true + -e '.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json' | + while IFS= read -r path; do + # Also allowed: an ownership record whose package is a case variant, such as GitHub/Squad/workflows. + if [[ "${path}" =~ ^\.github/aw/packages/[^/]+\.json$ ]] && + git show "HEAD:${path}" | node -e ' + const accepted = ["github/squad/workflows", "bradygaster/squad/workflows"]; + let text = ""; + process.stdin.on("data", chunk => { text += chunk; }).on("end", () => { + let name; + try { name = JSON.parse(text).package; } catch {} + process.exit(typeof name === "string" && !accepted.includes(name) + && accepted.includes(name.toLowerCase()) ? 0 : 1); + });'; then + continue + fi + printf '%s\n' "${path}" + done || true )" test -z "${unexpected_deletions}" || { printf 'STOP: unexpected staged deletions:\n%s\n' "${unexpected_deletions}" >&2 @@ -486,9 +496,12 @@ test -z "${unexpected_deletions}" || { - The only permitted staged deletions are `.github/skills/agentic-workflows/SKILL.md`, when upgrading a repository that - previously committed gh-aw's mutable router, and + previously committed gh-aw's mutable router, `.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json`, when moving - an existing installation off the legacy `bradygaster/squad/workflows` identity. + an existing installation off the legacy `bradygaster/squad/workflows` identity, + and a `.github/aw/packages/*.json` ownership record whose `package` is a case + variant of `github/squad/workflows` or `bradygaster/squad/workflows`, such as + one left by an install typed as `GitHub/Squad/workflows`. - **STOP** if the staged diff shows any other **unexpected deletions**, **unexpected secrets**, edits to **unrelated files**, or committed **log/diagnostic output**. Re-scope with explicit `git add -- ` — never `git add .`, `git add -A`, or `git commit -a`. @@ -636,7 +649,7 @@ test "${squad_package}" = "github/squad/workflows" || { echo "Check gh auth status --hostname github.com and network access, and confirm SQUAD_SHA is a github/squad commit that has this manifest, then retry." >&2 else echo "STOP: Squad ${SQUAD_SHA} does not declare the github/squad/workflows package (found '${squad_package}')." >&2 - echo "Install a revision that declares bradygaster/squad/workflows under that identity, or ask the Squad maintainers for a newer revision." >&2 + echo "Use a revision whose manifest declares github/squad/workflows, or ask the Squad maintainers for one. Older revisions download the Squad CLI from the bradygaster/squad fork, which has no releases." >&2 fi exit 1 } @@ -708,9 +721,7 @@ gh pr merge --squash # auto-merge before human review. NEVER. `.github/skills/agentic-workflows/SKILL.md`; keep the exact Squad-owned `.github/skills/gh-aw-enlistment/SKILL.md`. - ❌ **Approving unknown safe updates.** Approve ONLY `SQUAD_GITHUB_APP_PRIVATE_KEY`, - `SQUAD_GITHUB_TOKEN`, and `github/squad/.github/actions/squad-init` (reported - as `bradygaster/squad/.github/actions/squad-init` only at a revision whose - manifest declares `bradygaster/squad/workflows`). Anything + `SQUAD_GITHUB_TOKEN`, and `github/squad/.github/actions/squad-init`. Anything else is a STOP. - ❌ **Treating `--approve` as the final compile.** Always finish with a plain `gh aw compile --strict` (no `--approve`). diff --git a/packages/squad-sdk/templates/skills/gh-aw-enlistment/SKILL.md b/packages/squad-sdk/templates/skills/gh-aw-enlistment/SKILL.md index 9839df422..c69782bac 100644 --- a/packages/squad-sdk/templates/skills/gh-aw-enlistment/SKILL.md +++ b/packages/squad-sdk/templates/skills/gh-aw-enlistment/SKILL.md @@ -203,7 +203,7 @@ test "${squad_package}" = "github/squad/workflows" || { echo "Check gh auth status --hostname github.com and network access, and confirm SQUAD_SHA is a github/squad commit that has this manifest, then retry." >&2 else echo "STOP: Squad ${SQUAD_SHA} does not declare the github/squad/workflows package (found '${squad_package}')." >&2 - echo "Install a revision that declares bradygaster/squad/workflows under that identity, or ask the Squad maintainers for a newer revision." >&2 + echo "Use a revision whose manifest declares github/squad/workflows, or ask the Squad maintainers for one. Older revisions download the Squad CLI from the bradygaster/squad fork, which has no releases." >&2 fi exit 1 } @@ -215,11 +215,11 @@ rm -f .github/skills/agentic-workflows/SKILL.md `github/squad/workflows` verifies only at Squad revisions whose `workflows/squad-workflows.manifest.json` declares `"package": "github/squad/workflows"`. The identity check above reads that -manifest at `SQUAD_SHA` and stops on any other revision. An older -maintainer-approved revision declares `bradygaster/squad/workflows`: install it -under that identity, because its verifier accepts no other, or ask the Squad -maintainers for a newer revision. Such a revision's safe-update report names -the same squad-init action as `bradygaster/squad/.github/actions/squad-init`. +manifest at `SQUAD_SHA` and stops on any other revision. Do not install an +older revision under `bradygaster/squad/workflows` instead: its activation +downloads the Squad CLI from the separately controlled `bradygaster/squad` +fork, which has no releases. Use a revision whose manifest declares +`github/squad/workflows`, or ask the Squad maintainers for one. The nested `workflows/aw.yml` is the only supported distribution registration. It isolates package auto-discovery from unrelated repository skills and agents, @@ -255,9 +255,10 @@ Revisions whose `workflows/squad-workflows.manifest.json` declares `bradygaster/squad/workflows` identity. Earlier revisions, including ones committed after the repository moved to `github/squad`, declare and verify only `bradygaster/squad/workflows`, and installations made from them carry it in -their ownership record, workflow `source:` lines, and locks. Upgrade such an -installation to a revision that declares `github/squad/workflows` with the block -below. gh-aw keeps the superseded +their ownership record, workflow `source:` lines, and locks. Those revisions +download the Squad CLI from the separately controlled `bradygaster/squad` fork, +which has no releases, so move such an installation to a revision that declares +`github/squad/workflows` with the block below. gh-aw keeps the superseded `.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json` after the forced reinstall, so the block deletes exactly that file before verifying; the verifier fails closed while both ownership records exist. If the forced @@ -268,13 +269,10 @@ also makes `gh aw add` report one safe-update change: action squad-init action under the repository's new owner; list it in the PR's security review note. **STOP** on any other new secret or action. Until the upgrade is committed, every strict compile repeats that change as four -warnings, which step 5 allows only for this migration. Until an -installation moves, `--verify-install` still passes and prints a notice naming -the legacy identity. To stay on a revision that declares -`bradygaster/squad/workflows`, drop the identity check below, keep the -`gh aw add` line's STOP guard but replace its command with -`gh aw add "bradygaster/squad/workflows@${SQUAD_SHA}" --force`, and skip the -second `rm -f`: that ownership record is the only one such an installation has. +warnings, which step 5 allows only for this migration. Verifiers of revisions +that declare `github/squad/workflows` still accept the legacy identity, so an +existing installation keeps verifying while it moves, and they print a notice +that says to move it. ```bash : "${SQUAD_SHA:?STOP: set SQUAD_SHA to an explicit, maintainer-approved 40-character Squad commit SHA before upgrading.}" @@ -291,7 +289,7 @@ test "${squad_package}" = "github/squad/workflows" || { echo "Check gh auth status --hostname github.com and network access, and confirm SQUAD_SHA is a github/squad commit that has this manifest, then retry." >&2 else echo "STOP: Squad ${SQUAD_SHA} does not declare the github/squad/workflows package (found '${squad_package}')." >&2 - echo "Install a revision that declares bradygaster/squad/workflows under that identity, or ask the Squad maintainers for a newer revision." >&2 + echo "Use a revision whose manifest declares github/squad/workflows, or ask the Squad maintainers for one. Older revisions download the Squad CLI from the bradygaster/squad fork, which has no releases." >&2 fi exit 1 } @@ -335,10 +333,6 @@ On a clean repo, `gh aw add` reports these expected safe updates and **nothing e - Action: **`github/squad/.github/actions/squad-init`** -At a revision whose manifest declares `bradygaster/squad/workflows`, the same -action is reported as `bradygaster/squad/.github/actions/squad-init`. Accept -that name only for such a revision. - > **These are referenced names, not prerequisites.** `gh aw add` lists the secrets > the workflows *reference* so you can approve that surface — it is not asking you > to supply them. Both secrets are optional, they need not exist, and neither is @@ -476,7 +470,23 @@ git diff --cached --stat unexpected_deletions="$( git diff --cached --diff-filter=D --name-only | grep -vxF -e '.github/skills/agentic-workflows/SKILL.md' \ - -e '.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json' || true + -e '.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json' | + while IFS= read -r path; do + # Also allowed: an ownership record whose package is a case variant, such as GitHub/Squad/workflows. + if [[ "${path}" =~ ^\.github/aw/packages/[^/]+\.json$ ]] && + git show "HEAD:${path}" | node -e ' + const accepted = ["github/squad/workflows", "bradygaster/squad/workflows"]; + let text = ""; + process.stdin.on("data", chunk => { text += chunk; }).on("end", () => { + let name; + try { name = JSON.parse(text).package; } catch {} + process.exit(typeof name === "string" && !accepted.includes(name) + && accepted.includes(name.toLowerCase()) ? 0 : 1); + });'; then + continue + fi + printf '%s\n' "${path}" + done || true )" test -z "${unexpected_deletions}" || { printf 'STOP: unexpected staged deletions:\n%s\n' "${unexpected_deletions}" >&2 @@ -486,9 +496,12 @@ test -z "${unexpected_deletions}" || { - The only permitted staged deletions are `.github/skills/agentic-workflows/SKILL.md`, when upgrading a repository that - previously committed gh-aw's mutable router, and + previously committed gh-aw's mutable router, `.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json`, when moving - an existing installation off the legacy `bradygaster/squad/workflows` identity. + an existing installation off the legacy `bradygaster/squad/workflows` identity, + and a `.github/aw/packages/*.json` ownership record whose `package` is a case + variant of `github/squad/workflows` or `bradygaster/squad/workflows`, such as + one left by an install typed as `GitHub/Squad/workflows`. - **STOP** if the staged diff shows any other **unexpected deletions**, **unexpected secrets**, edits to **unrelated files**, or committed **log/diagnostic output**. Re-scope with explicit `git add -- ` — never `git add .`, `git add -A`, or `git commit -a`. @@ -636,7 +649,7 @@ test "${squad_package}" = "github/squad/workflows" || { echo "Check gh auth status --hostname github.com and network access, and confirm SQUAD_SHA is a github/squad commit that has this manifest, then retry." >&2 else echo "STOP: Squad ${SQUAD_SHA} does not declare the github/squad/workflows package (found '${squad_package}')." >&2 - echo "Install a revision that declares bradygaster/squad/workflows under that identity, or ask the Squad maintainers for a newer revision." >&2 + echo "Use a revision whose manifest declares github/squad/workflows, or ask the Squad maintainers for one. Older revisions download the Squad CLI from the bradygaster/squad fork, which has no releases." >&2 fi exit 1 } @@ -708,9 +721,7 @@ gh pr merge --squash # auto-merge before human review. NEVER. `.github/skills/agentic-workflows/SKILL.md`; keep the exact Squad-owned `.github/skills/gh-aw-enlistment/SKILL.md`. - ❌ **Approving unknown safe updates.** Approve ONLY `SQUAD_GITHUB_APP_PRIVATE_KEY`, - `SQUAD_GITHUB_TOKEN`, and `github/squad/.github/actions/squad-init` (reported - as `bradygaster/squad/.github/actions/squad-init` only at a revision whose - manifest declares `bradygaster/squad/workflows`). Anything + `SQUAD_GITHUB_TOKEN`, and `github/squad/.github/actions/squad-init`. Anything else is a STOP. - ❌ **Treating `--approve` as the final compile.** Always finish with a plain `gh aw compile --strict` (no `--approve`). diff --git a/templates/skills/gh-aw-enlistment/SKILL.md b/templates/skills/gh-aw-enlistment/SKILL.md index 9839df422..c69782bac 100644 --- a/templates/skills/gh-aw-enlistment/SKILL.md +++ b/templates/skills/gh-aw-enlistment/SKILL.md @@ -203,7 +203,7 @@ test "${squad_package}" = "github/squad/workflows" || { echo "Check gh auth status --hostname github.com and network access, and confirm SQUAD_SHA is a github/squad commit that has this manifest, then retry." >&2 else echo "STOP: Squad ${SQUAD_SHA} does not declare the github/squad/workflows package (found '${squad_package}')." >&2 - echo "Install a revision that declares bradygaster/squad/workflows under that identity, or ask the Squad maintainers for a newer revision." >&2 + echo "Use a revision whose manifest declares github/squad/workflows, or ask the Squad maintainers for one. Older revisions download the Squad CLI from the bradygaster/squad fork, which has no releases." >&2 fi exit 1 } @@ -215,11 +215,11 @@ rm -f .github/skills/agentic-workflows/SKILL.md `github/squad/workflows` verifies only at Squad revisions whose `workflows/squad-workflows.manifest.json` declares `"package": "github/squad/workflows"`. The identity check above reads that -manifest at `SQUAD_SHA` and stops on any other revision. An older -maintainer-approved revision declares `bradygaster/squad/workflows`: install it -under that identity, because its verifier accepts no other, or ask the Squad -maintainers for a newer revision. Such a revision's safe-update report names -the same squad-init action as `bradygaster/squad/.github/actions/squad-init`. +manifest at `SQUAD_SHA` and stops on any other revision. Do not install an +older revision under `bradygaster/squad/workflows` instead: its activation +downloads the Squad CLI from the separately controlled `bradygaster/squad` +fork, which has no releases. Use a revision whose manifest declares +`github/squad/workflows`, or ask the Squad maintainers for one. The nested `workflows/aw.yml` is the only supported distribution registration. It isolates package auto-discovery from unrelated repository skills and agents, @@ -255,9 +255,10 @@ Revisions whose `workflows/squad-workflows.manifest.json` declares `bradygaster/squad/workflows` identity. Earlier revisions, including ones committed after the repository moved to `github/squad`, declare and verify only `bradygaster/squad/workflows`, and installations made from them carry it in -their ownership record, workflow `source:` lines, and locks. Upgrade such an -installation to a revision that declares `github/squad/workflows` with the block -below. gh-aw keeps the superseded +their ownership record, workflow `source:` lines, and locks. Those revisions +download the Squad CLI from the separately controlled `bradygaster/squad` fork, +which has no releases, so move such an installation to a revision that declares +`github/squad/workflows` with the block below. gh-aw keeps the superseded `.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json` after the forced reinstall, so the block deletes exactly that file before verifying; the verifier fails closed while both ownership records exist. If the forced @@ -268,13 +269,10 @@ also makes `gh aw add` report one safe-update change: action squad-init action under the repository's new owner; list it in the PR's security review note. **STOP** on any other new secret or action. Until the upgrade is committed, every strict compile repeats that change as four -warnings, which step 5 allows only for this migration. Until an -installation moves, `--verify-install` still passes and prints a notice naming -the legacy identity. To stay on a revision that declares -`bradygaster/squad/workflows`, drop the identity check below, keep the -`gh aw add` line's STOP guard but replace its command with -`gh aw add "bradygaster/squad/workflows@${SQUAD_SHA}" --force`, and skip the -second `rm -f`: that ownership record is the only one such an installation has. +warnings, which step 5 allows only for this migration. Verifiers of revisions +that declare `github/squad/workflows` still accept the legacy identity, so an +existing installation keeps verifying while it moves, and they print a notice +that says to move it. ```bash : "${SQUAD_SHA:?STOP: set SQUAD_SHA to an explicit, maintainer-approved 40-character Squad commit SHA before upgrading.}" @@ -291,7 +289,7 @@ test "${squad_package}" = "github/squad/workflows" || { echo "Check gh auth status --hostname github.com and network access, and confirm SQUAD_SHA is a github/squad commit that has this manifest, then retry." >&2 else echo "STOP: Squad ${SQUAD_SHA} does not declare the github/squad/workflows package (found '${squad_package}')." >&2 - echo "Install a revision that declares bradygaster/squad/workflows under that identity, or ask the Squad maintainers for a newer revision." >&2 + echo "Use a revision whose manifest declares github/squad/workflows, or ask the Squad maintainers for one. Older revisions download the Squad CLI from the bradygaster/squad fork, which has no releases." >&2 fi exit 1 } @@ -335,10 +333,6 @@ On a clean repo, `gh aw add` reports these expected safe updates and **nothing e - Action: **`github/squad/.github/actions/squad-init`** -At a revision whose manifest declares `bradygaster/squad/workflows`, the same -action is reported as `bradygaster/squad/.github/actions/squad-init`. Accept -that name only for such a revision. - > **These are referenced names, not prerequisites.** `gh aw add` lists the secrets > the workflows *reference* so you can approve that surface — it is not asking you > to supply them. Both secrets are optional, they need not exist, and neither is @@ -476,7 +470,23 @@ git diff --cached --stat unexpected_deletions="$( git diff --cached --diff-filter=D --name-only | grep -vxF -e '.github/skills/agentic-workflows/SKILL.md' \ - -e '.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json' || true + -e '.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json' | + while IFS= read -r path; do + # Also allowed: an ownership record whose package is a case variant, such as GitHub/Squad/workflows. + if [[ "${path}" =~ ^\.github/aw/packages/[^/]+\.json$ ]] && + git show "HEAD:${path}" | node -e ' + const accepted = ["github/squad/workflows", "bradygaster/squad/workflows"]; + let text = ""; + process.stdin.on("data", chunk => { text += chunk; }).on("end", () => { + let name; + try { name = JSON.parse(text).package; } catch {} + process.exit(typeof name === "string" && !accepted.includes(name) + && accepted.includes(name.toLowerCase()) ? 0 : 1); + });'; then + continue + fi + printf '%s\n' "${path}" + done || true )" test -z "${unexpected_deletions}" || { printf 'STOP: unexpected staged deletions:\n%s\n' "${unexpected_deletions}" >&2 @@ -486,9 +496,12 @@ test -z "${unexpected_deletions}" || { - The only permitted staged deletions are `.github/skills/agentic-workflows/SKILL.md`, when upgrading a repository that - previously committed gh-aw's mutable router, and + previously committed gh-aw's mutable router, `.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json`, when moving - an existing installation off the legacy `bradygaster/squad/workflows` identity. + an existing installation off the legacy `bradygaster/squad/workflows` identity, + and a `.github/aw/packages/*.json` ownership record whose `package` is a case + variant of `github/squad/workflows` or `bradygaster/squad/workflows`, such as + one left by an install typed as `GitHub/Squad/workflows`. - **STOP** if the staged diff shows any other **unexpected deletions**, **unexpected secrets**, edits to **unrelated files**, or committed **log/diagnostic output**. Re-scope with explicit `git add -- ` — never `git add .`, `git add -A`, or `git commit -a`. @@ -636,7 +649,7 @@ test "${squad_package}" = "github/squad/workflows" || { echo "Check gh auth status --hostname github.com and network access, and confirm SQUAD_SHA is a github/squad commit that has this manifest, then retry." >&2 else echo "STOP: Squad ${SQUAD_SHA} does not declare the github/squad/workflows package (found '${squad_package}')." >&2 - echo "Install a revision that declares bradygaster/squad/workflows under that identity, or ask the Squad maintainers for a newer revision." >&2 + echo "Use a revision whose manifest declares github/squad/workflows, or ask the Squad maintainers for one. Older revisions download the Squad CLI from the bradygaster/squad fork, which has no releases." >&2 fi exit 1 } @@ -708,9 +721,7 @@ gh pr merge --squash # auto-merge before human review. NEVER. `.github/skills/agentic-workflows/SKILL.md`; keep the exact Squad-owned `.github/skills/gh-aw-enlistment/SKILL.md`. - ❌ **Approving unknown safe updates.** Approve ONLY `SQUAD_GITHUB_APP_PRIVATE_KEY`, - `SQUAD_GITHUB_TOKEN`, and `github/squad/.github/actions/squad-init` (reported - as `bradygaster/squad/.github/actions/squad-init` only at a revision whose - manifest declares `bradygaster/squad/workflows`). Anything + `SQUAD_GITHUB_TOKEN`, and `github/squad/.github/actions/squad-init`. Anything else is a STOP. - ❌ **Treating `--approve` as the final compile.** Always finish with a plain `gh aw compile --strict` (no `--approve`). diff --git a/test/gh-aw-enlistment-skill.test.ts b/test/gh-aw-enlistment-skill.test.ts index 7fe4c6819..2e4583cba 100644 --- a/test/gh-aw-enlistment-skill.test.ts +++ b/test/gh-aw-enlistment-skill.test.ts @@ -18,6 +18,7 @@ */ import { describe, it, expect, beforeAll } from 'vitest'; +import { createHash } from 'node:crypto'; import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { execFileSync, spawnSync } from 'node:child_process'; import { resolve, dirname } from 'node:path'; @@ -54,10 +55,12 @@ const [LEGACY_PACKAGE_NAME] = LEGACY_PACKAGE_NAMES; const [LEGACY_OWNERSHIP] = LEGACY_OWNERSHIP_DESTINATIONS; const SQUAD_INIT_ACTION = `${PACKAGE_NAME.replace(/\/workflows$/, '')}/.github/actions/squad-init`; const LEGACY_SQUAD_INIT_ACTION = `${LEGACY_PACKAGE_NAME.replace(/\/workflows$/, '')}/.github/actions/squad-init`; -const DELETION_FILTER = [ +const DELETION_ALLOWLIST = [ ` grep -vxF -e '${UNOWNED_MUTABLE_ROUTER_SKILL}' \\`, - ` -e '${LEGACY_OWNERSHIP}' || true`, + ` -e '${LEGACY_OWNERSHIP}' |`, + ' while IFS= read -r path; do', ].join('\n'); +const DELETION_FILTER_END = ' done || true\n'; function extractDeletionGate(content: string): string { const start = content.indexOf('unexpected_deletions="$('); @@ -67,26 +70,66 @@ function extractDeletionGate(content: string): string { return content.slice(start, end + 2); } -function runDeletionGate(gate: string, deletions: readonly string[]) { +/** The gate's filter: the exact allowlist, then the loop that admits only case-variant ownership records. */ +function extractDeletionFilter(content: string): string { + const gate = extractDeletionGate(content); + const start = gate.indexOf(DELETION_ALLOWLIST); + const end = gate.indexOf(DELETION_FILTER_END, start); + expect(start, 'the staged-deletion filter must start with the exact allowlist').toBeGreaterThan(-1); + expect(end, 'the staged-deletion filter must end its case-variant loop').toBeGreaterThan(start); + return gate.slice(start, end + DELETION_FILTER_END.length); +} + +/** Runs the gate with stubbed staged deletions; `head` holds the HEAD blob of each deleted path. */ +function runDeletionGate(gate: string, deletions: readonly string[], head: Readonly> = {}) { const script = ` set -euo pipefail -git() { printf '%s' "$STAGED_DELETIONS"; } +git() { + case "$1" in + show) node -e ' + const head = JSON.parse(process.env.HEAD_BLOBS); + const path = process.argv[1].replace(/^HEAD:/, ""); + if (!Object.hasOwn(head, path)) { + console.error("fatal: path " + path + " does not exist in HEAD"); + process.exit(128); + } + process.stdout.write(head[path]);' "$2" ;; + *) printf '%s' "$STAGED_DELETIONS" ;; + esac +} ${gate} `; return spawnSync('bash', ['-c', script], { encoding: 'utf8', - env: { ...process.env, STAGED_DELETIONS: deletions.map(path => `${path}\n`).join('') }, + env: { + ...process.env, + STAGED_DELETIONS: deletions.map(path => `${path}\n`).join(''), + HEAD_BLOBS: JSON.stringify(head), + }, }); } +/** gh-aw v0.91.5 names an ownership record by the identity exactly as typed (see packageOwnershipDestination). */ +function typedOwnershipRecord(packageName: string): string { + const slug = packageName.toLowerCase().replace(/[/\\:@]/g, '-').replace(/^-+|-+$/g, ''); + return `.github/aw/packages/${slug}-${createHash('sha256').update(packageName).digest('hex').slice(0, 12)}.json`; +} + +const CASE_VARIANT_PACKAGE = 'GitHub/Squad/workflows'; +const CASE_VARIANT_OWNERSHIP = typedOwnershipRecord(CASE_VARIANT_PACKAGE); +const LEGACY_CASE_VARIANT_PACKAGE = 'Bradygaster/Squad/workflows'; +const LEGACY_CASE_VARIANT_OWNERSHIP = typedOwnershipRecord(LEGACY_CASE_VARIANT_PACKAGE); +const ownershipBlob = (packageName: string) => `${JSON.stringify({ schemaVersion: 1, package: packageName })}\n`; + const IDENTITY_GATE_START = '# squad-package-identity-start'; const IDENTITY_GATE_END = '# squad-package-identity-end'; const IDENTITY_SHA = 'c'.repeat(40); const LEGACY_INSTALL_PREFIX = `gh aw add "${LEGACY_PACKAGE_NAME}@`; const MANIFEST_RULE = `\`${PACKAGE_NAME}\` verifies only at Squad revisions whose ` + `\`workflows/squad-workflows.manifest.json\` declares \`"package": "${PACKAGE_NAME}"\``; -const IDENTITY_LEGACY_HINT = `Install a revision that declares ${LEGACY_PACKAGE_NAME} under that identity, ` - + 'or ask the Squad maintainers for a newer revision.'; +const IDENTITY_CANONICAL_HINT = `Use a revision whose manifest declares ${PACKAGE_NAME}, or ask the Squad maintainers ` + + `for one. Older revisions download the Squad CLI from the ${LEGACY_PACKAGE_NAME.replace(/\/workflows$/, '')} ` + + 'fork, which has no releases.'; const IDENTITY_UNREADABLE_STOP = 'STOP: cannot read workflows/squad-workflows.manifest.json from github/squad ' + `at ${IDENTITY_SHA}.`; const IDENTITY_UNREADABLE_HINT = 'Check gh auth status --hostname github.com and network access, ' @@ -587,6 +630,92 @@ describe('SQUAD_SHA guard before every manifest identity check', () => { ); }); +const VERIFIER = 'workflows/shared/squad-install-verifier.mjs'; +const LEGACY_HINT_SURFACES = [ + CANONICAL, ...MIRRORS, GUIDE, 'README.md', AGENT_GUIDE, 'workflows/README.md', VERIFIER, +] as const; +// Revisions that declare only the legacy identity download the Squad CLI from the bradygaster/squad +// fork, so no install hint may send users there. Each shape is one way earlier text did that. +const LEGACY_INSTALL_SHAPES: ReadonlyArray = [ + ['legacy install command', /gh aw add "?bradygaster\/squad\/workflows@/g], + ['install under the legacy identity', /\binstall\b[^.;:]*?\bunder (?:that|the legacy|this) identity\b/gi], + ['install an older revision', /(? block.split('\n').map(line => + [...line.matchAll(/\b(?:echo|printf)\s+(?:"((?:[^"\\]|\\.)*)"|'([^']*)')/g)] + .map(match => match[1] ?? match[2]).join(' ')).join('\n')); +} + +function legacyInstallHints(path: string): string[] { + const text = readLF(path); + const findings: string[] = []; + const scan = (source: string, shapes: ReadonlyArray) => { + const { flat, lines } = flattenLines(source); + for (const [label, shape] of shapes) { + for (const match of flat.matchAll(shape)) { + findings.push(`${path}:${lines[match.index!]}: ${label}: "${match[0].slice(0, 200)}"`); + } + } + }; + scan(text, LEGACY_INSTALL_SHAPES); + if (path.endsWith('.md')) scan(readableMarkdown(text), [['install the legacy identity', LEGACY_INSTALL_CLAUSE]]); + return findings; +} + +describe('no install-time fallback to the legacy package identity', () => { + it.each(LEGACY_HINT_SURFACES)('%s never tells users to install or stay on the legacy identity', path => { + expect(legacyInstallHints(path), `${path}: install hints or STOP messages that send users to ` + + `${LEGACY_PACKAGE_NAME} (file:line: shape: text)`).toEqual([]); + }); + + it('keeps the legacy-to-canonical move and the verifier\'s transitional acceptance of the legacy identity', () => { + for (const path of [CANONICAL, GUIDE]) { + const text = readLF(path); + expect(fencedBlocks(text).some(block => block.includes(`${INSTALL_COMMAND} --force`) + && block.includes(`\nrm -f ${LEGACY_OWNERSHIP}\n`)), `${path} must keep the upgrade's legacy-record cleanup`) + .toBe(true); + expect(text, `${path} must keep the staged-deletion allowance for ${LEGACY_OWNERSHIP}`) + .toContain(`-e '${LEGACY_OWNERSHIP}'`); + expect(text, `${path} must keep the migration warning allowlist`).toContain(`- ${LEGACY_SQUAD_INIT_ACTION}\n`); + } + expect(LEGACY_PACKAGE_NAMES, `${VERIFIER} must keep accepting existing legacy installations`) + .toEqual(['bradygaster/squad/workflows']); + expect(readLF(VERIFIER)).toContain('Object.freeze([PACKAGE_NAME, ...LEGACY_PACKAGE_NAMES])'); + }); +}); + describe('gh-aw legacy-identity migration with committed legacy locks', () => { let outputs: { approved: string; upgraded: string; committed: string }; beforeAll(() => { @@ -771,7 +900,8 @@ describe('gh-aw-enlistment skill', () => { expect(content).toContain('not part of the Squad package'); expect(content).toContain('Do not adopt or vendor the rest'); expect(content).toContain('.github/skills/gh-aw-enlistment/SKILL.md'); - expect(content).toContain(DELETION_FILTER); + expect(content, `${CANONICAL} must use the exact staged-deletion allowlist, then its case-variant loop`) + .toContain(DELETION_ALLOWLIST); }); it.each([ @@ -790,6 +920,49 @@ describe('gh-aw-enlistment skill', () => { } }); + // gh-aw keys records by the identity as typed, so `gh aw add GitHub/Squad/workflows@SHA` leaves a + // record that the verifier requires to be deleted, and the gate must let exactly that deletion through. + it.each([ + [`the ${CASE_VARIANT_PACKAGE} record`, [CASE_VARIANT_OWNERSHIP], + { [CASE_VARIANT_OWNERSHIP]: ownershipBlob(CASE_VARIANT_PACKAGE) }, []], + [`the ${LEGACY_CASE_VARIANT_PACKAGE} record`, [LEGACY_CASE_VARIANT_OWNERSHIP], + { [LEGACY_CASE_VARIANT_OWNERSHIP]: ownershipBlob(LEGACY_CASE_VARIANT_PACKAGE) }, []], + ['the router, the legacy record and a case-variant record together', + [UNOWNED_MUTABLE_ROUTER_SKILL, LEGACY_OWNERSHIP, CASE_VARIANT_OWNERSHIP], + { [CASE_VARIANT_OWNERSHIP]: ownershipBlob(CASE_VARIANT_PACKAGE) }, []], + [`an exact ${PACKAGE_NAME} record at a case-variant path`, [CASE_VARIANT_OWNERSHIP], + { [CASE_VARIANT_OWNERSHIP]: ownershipBlob(PACKAGE_NAME) }, [CASE_VARIANT_OWNERSHIP]], + ['the canonical ownership record', [OWNERSHIP_DESTINATION], + { [OWNERSHIP_DESTINATION]: ownershipBlob(PACKAGE_NAME) }, [OWNERSHIP_DESTINATION]], + ['another package\'s ownership record', ['.github/aw/packages/acme-tools-0123456789ab.json'], + { '.github/aw/packages/acme-tools-0123456789ab.json': ownershipBlob('acme/tools') }, + ['.github/aw/packages/acme-tools-0123456789ab.json']], + ['a record that is not JSON', [CASE_VARIANT_OWNERSHIP], + { [CASE_VARIANT_OWNERSHIP]: `${CASE_VARIANT_PACKAGE}\n` }, [CASE_VARIANT_OWNERSHIP]], + ['a record without a package', [CASE_VARIANT_OWNERSHIP], + { [CASE_VARIANT_OWNERSHIP]: '{"schemaVersion":1}\n' }, [CASE_VARIANT_OWNERSHIP]], + ['a record missing from HEAD', [CASE_VARIANT_OWNERSHIP], {}, [CASE_VARIANT_OWNERSHIP]], + ['a case-variant record outside .github/aw/packages/', ['.github/aw/github-squad-workflows.json'], + { '.github/aw/github-squad-workflows.json': ownershipBlob(CASE_VARIANT_PACKAGE) }, + ['.github/aw/github-squad-workflows.json']], + ['a case-variant record nested below .github/aw/packages/', ['.github/aw/packages/old/record.json'], + { '.github/aw/packages/old/record.json': ownershipBlob(CASE_VARIANT_PACKAGE) }, + ['.github/aw/packages/old/record.json']], + ['a case-variant record without the .json suffix', [`${CASE_VARIANT_OWNERSHIP}.bak`], + { [`${CASE_VARIANT_OWNERSHIP}.bak`]: ownershipBlob(CASE_VARIANT_PACKAGE) }, [`${CASE_VARIANT_OWNERSHIP}.bak`]], + ['a case-variant record beside an installed workflow', [CASE_VARIANT_OWNERSHIP, '.github/workflows/squad.md'], + { [CASE_VARIANT_OWNERSHIP]: ownershipBlob(CASE_VARIANT_PACKAGE) }, ['.github/workflows/squad.md']], + ] as const)('the staged-deletion gate permits only case-variant ownership records: %s', ( + label, deletions, head, stopped, + ) => { + const result = runDeletionGate(extractDeletionGate(content), deletions, head); + const input = `${CANONICAL} staged-deletion gate with ${label} (${deletions.join(', ')}; HEAD ${JSON.stringify(head)})`; + expect(result.status, `${input}: ${result.stderr}`).toBe(stopped.length === 0 ? 0 : 1); + if (stopped.length > 0) { + expect(result.stderr, input).toContain(`STOP: unexpected staged deletions:\n${stopped.join('\n')}\n`); + } + }); + it('documents the legacy-identity migration that the verifier enforces', () => { const flat = content.replace(/\s+/g, ' '); const why = `skill must document migrating off ${LEGACY_PACKAGE_NAME} and deleting ${LEGACY_OWNERSHIP}`; @@ -805,10 +978,14 @@ describe('gh-aw-enlistment skill', () => { `action \`${SQUAD_INIT_ACTION}\` added and \`bradygaster/squad/.github/actions/squad-init\` removed.`, ); expect(flat, why).toContain('**STOP** on any other new secret or action.'); - expect(flat, 'skill must say how to stay on a legacy-only revision').toContain( - 'drop the identity check below, keep the `gh aw add` line\'s STOP guard but replace its command ' - + `with \`${LEGACY_INSTALL_PREFIX}\${SQUAD_SHA}" --force\`, and skip the second \`rm -f\`: ` - + 'that ownership record is the only one such an installation has.', + expect(flat, 'skill must say why a legacy-identity installation has to move').toContain( + 'Those revisions download the Squad CLI from the separately controlled `bradygaster/squad` fork, ' + + `which has no releases, so move such an installation to a revision that declares \`${PACKAGE_NAME}\` ` + + 'with the block below.', + ); + expect(flat, 'skill must accept the legacy identity only while an installation moves').toContain( + `Verifiers of revisions that declare \`${PACKAGE_NAME}\` still accept the legacy identity, so an existing ` + + 'installation keeps verifying while it moves, and they print a notice that says to move it.', ); expect(flat, 'skill must say a failed reinstall removes nothing') .toContain('If the forced reinstall fails, the block stops before it removes anything.'); @@ -861,9 +1038,10 @@ describe('gh-aw-enlistment skill', () => { .toBe(3); const flat = content.replace(/\s+/g, ' '); expect(flat, 'skill must state the manifest cutoff').toContain(MANIFEST_RULE); - expect(flat, 'skill must route older revisions').toContain( - `An older maintainer-approved revision declares \`${LEGACY_PACKAGE_NAME}\`: install it under that identity, ` - + 'because its verifier accepts no other, or ask the Squad maintainers for a newer revision.', + expect(flat, 'skill must route older revisions to a canonical revision, not to the legacy identity').toContain( + `Do not install an older revision under \`${LEGACY_PACKAGE_NAME}\` instead: its activation downloads the ` + + 'Squad CLI from the separately controlled `bradygaster/squad` fork, which has no releases. ' + + `Use a revision whose manifest declares \`${PACKAGE_NAME}\`, or ask the Squad maintainers for one.`, ); }); @@ -897,17 +1075,28 @@ describe('gh-aw-enlistment skill', () => { expect(result.stderr, label).toContain( `STOP: Squad ${IDENTITY_SHA} does not declare the ${PACKAGE_NAME} package (found '${found}').`, ); - expect(result.stderr, `${label}: a readable manifest routes to the legacy identity`) - .toContain(IDENTITY_LEGACY_HINT); + expect(result.stderr, `${label}: a readable manifest routes to a ${PACKAGE_NAME} revision`) + .toContain(IDENTITY_CANONICAL_HINT); expect(result.stderr, `${label}: a readable manifest is not an access problem`) .not.toContain(IDENTITY_UNREADABLE_HINT); }); - it('names the legacy squad-init action only for revisions that declare the legacy identity', () => { - const flat = content.replace(/\s+/g, ' '); - expect(flat).toContain( - `At a revision whose manifest declares \`${LEGACY_PACKAGE_NAME}\`, the same action is reported as ` - + '`bradygaster/squad/.github/actions/squad-init`. Accept that name only for such a revision.', + it('names the legacy squad-init action only for the legacy-identity migration', () => { + const lines = content.split('\n'); + const reviewHeading = lines.find(line => line.startsWith('### ') + && line.endsWith('Review the first-install safe-update report — approve ONLY the documented entries')); + expect(reviewHeading, `${CANONICAL} must keep its safe-update review step`).toBeDefined(); + const reviewStart = content.indexOf(`${reviewHeading}\n`); + const review = content.slice(reviewStart, content.indexOf('\n### ', reviewStart + 1)); + const antiPatterns = content.slice(content.indexOf('\n## Anti-Patterns\n')); + expect(antiPatterns, `${CANONICAL} must keep its Anti-Patterns section`).toContain('Approving unknown safe updates'); + for (const [name, text] of [[reviewHeading!, review], ['## Anti-Patterns', antiPatterns]] as const) { + expect(text, `${CANONICAL} "${name}" must not offer ${LEGACY_SQUAD_INIT_ACTION} as an approvable name`) + .not.toContain(LEGACY_SQUAD_INIT_ACTION); + } + expect(antiPatterns.replace(/\s+/g, ' ')).toContain( + `Approve ONLY \`SQUAD_GITHUB_APP_PRIVATE_KEY\`, \`SQUAD_GITHUB_TOKEN\`, and \`${SQUAD_INIT_ACTION}\`. ` + + 'Anything else is a STOP.', ); }); @@ -1090,9 +1279,13 @@ describe('gh-aw-enlistment skill', () => { 'The only permitted staged deletions are the exact router deletion above', ); expect(flatGuide).toContain(`the superseded \`${LEGACY_OWNERSHIP}\` ownership record`); + expect(flatGuide, 'the guide must name the case-variant ownership-record deletion the verifier requires') + .toContain(`and a \`.github/aw/packages/*.json\` ownership record whose \`package\` is a case variant of ` + + `\`${PACKAGE_NAME}\` or \`${LEGACY_PACKAGE_NAME}\`, such as one left by an install typed as ` + + `\`${CASE_VARIANT_PACKAGE}\`. Any other deletion is a hard stop.`); expect(flatGuide).toContain('Any other deletion is a hard stop.'); - expect(guide.split(DELETION_FILTER).length - 1, 'both guide staging gates must use the skill filter') - .toBe(2); + expect(guide.split(extractDeletionFilter(readLF(CANONICAL))).length - 1, + `both ${GUIDE} staging gates must use the ${CANONICAL} deletion filter`).toBe(2); expect(guide).toContain('.github/skills/gh-aw-enlistment/SKILL.md'); expect(agentGuide).toContain('rm -f .github/skills/agentic-workflows/SKILL.md'); }); @@ -1111,10 +1304,8 @@ describe('gh-aw-enlistment skill', () => { } expect(guide).toContain(`# (${SQUAD_INIT_ACTION}) and nothing else. STOP and`); expect(guide).toContain(`- Action: \`${SQUAD_INIT_ACTION}\``); - expect(guide.replace(/\s+/g, ' ')).toContain( - `At a revision whose manifest declares \`${LEGACY_PACKAGE_NAME}\`, the same action is reported as ` - + '`bradygaster/squad/.github/actions/squad-init`.', - ); + expect(guide.replace(/\s+/g, ' '), `${GUIDE} must not offer ${LEGACY_SQUAD_INIT_ACTION} as a referenced name`) + .not.toContain(`reported as \`${LEGACY_SQUAD_INIT_ACTION}\``); expect(guide).toContain(`\`${PACKAGE_NAME}@SHA\` for direct package includes`); expect(guide).toContain(`\`${PACKAGE_NAME}/package/.md@SHA\` when installing dispatched`); }); @@ -1144,9 +1335,12 @@ describe('gh-aw-enlistment skill', () => { ] as const) { const flat = text.replace(/\s+/g, ' '); expect(flat, `${path} must state the manifest cutoff`).toContain(MANIFEST_RULE); - expect(flat, `${path} must route older revisions to the legacy identity`) - .toMatch(/older (?:maintainer-)?approved revision (?:as|under that identity|declares)/); - expect(flat, `${path} must offer a newer revision`).toMatch(/ask the (?:Squad )?maintainers for a newer revision/); + expect(flat, `${path} must say why older revisions are not installed`).toMatch( + /downloads? the Squad CLI from the separately controlled `bradygaster\/squad` fork, which has no releases/, + ); + expect(flat, `${path} must route older revisions to a ${PACKAGE_NAME} revision`).toMatch( + new RegExp(`declares \`${PACKAGE_NAME}\`, or ask the (?:Squad )?maintainers for one\\.`), + ); } }); @@ -1253,10 +1447,13 @@ describe('gh-aw-enlistment skill', () => { const gate = extractIdentityGate(readLF(CANONICAL)); expect(upgrade.indexOf(gate), 'Upgrading must run the identity gate').toBeGreaterThan(-1); expect(upgrade.indexOf(gate), 'Upgrading must check the identity before reinstalling').toBeLessThan(add); - expect(flatUpgrade, 'Upgrading must say how to stay on a legacy-only revision').toContain( - 'drop the identity check, keep the `gh aw add` line\'s STOP guard but replace its command ' - + `with \`${LEGACY_INSTALL_PREFIX}\${SQUAD_SHA}" --force\`, and skip the second \`rm -f\`: ` - + 'that ownership record is the only one such an installation has.', + expect(flatUpgrade, 'Upgrading must say why a legacy-identity installation has to move').toContain( + 'Those revisions download the Squad CLI from the separately controlled `bradygaster/squad` fork, which has ' + + 'no releases, so move such an installation with the block above.', + ); + expect(flatUpgrade, 'Upgrading must accept the legacy identity only while an installation moves').toContain( + `Verifiers of revisions that declare \`${PACKAGE_NAME}\` still accept the legacy identity, so an existing ` + + 'installation keeps verifying while it moves, and they print a notice that says to move it.', ); expect(flatUpgrade, 'Upgrading must say a failed reinstall removes nothing') .toContain('If the forced reinstall fails, the block stops before it removes anything.'); diff --git a/test/gh-aw-install-staging.test.ts b/test/gh-aw-install-staging.test.ts index 2c86e3226..abb8ae192 100644 --- a/test/gh-aw-install-staging.test.ts +++ b/test/gh-aw-install-staging.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { execFileSync, spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; @@ -182,10 +183,12 @@ describe('gh-aw: verified ownership staging under consumer ignore rules', () => expect(verifyStagedInstall(root, { expectedRevision: revision, stageOwnership: true }).failures).toEqual([]); const queries = spawn.mock.calls.filter(([command, args]) => command === 'git' && args?.[0] === 'ls-tree'); expect(queries).toHaveLength(1); - expect(queries[0][1], `ls-tree must stay bounded to exact paths, including superseded ${LEGACY_OWNERSHIP}`) + expect(queries[0][1], `ls-tree must stay bounded to exact paths, including superseded ${LEGACY_OWNERSHIP}, ` + + 'plus one level of .github/aw/packages/ for case-variant ownership records') .toEqual([ 'ls-tree', '-z', expect.any(String), '--', ...new Set(required), '.github/workflows/aw.json', UNOWNED_MUTABLE_ROUTER_SKILL, ...LEGACY_OWNERSHIP_DESTINATIONS, + '.github/aw/packages/', ]); const unrelatedObject = git(root, 'rev-parse', `HEAD:${unrelated}/ordinary-file-00000.txt`).trim(); const blobReads = spawn.mock.calls.filter(([command, args]) => command === 'git' && args?.[0] === 'cat-file'); @@ -350,6 +353,48 @@ describe('gh-aw: verified ownership staging under consumer ignore rules', () => expect(git(root, 'diff', '--cached', '--diff-filter=D', '--name-only').trim()).toBe(LEGACY_OWNERSHIP); }, 120_000); + it('rejects a case-variant ownership record deleted only from the working tree until its deletion is staged', () => { + const root = consumer(); + // `gh aw add GitHub/Squad/workflows@SHA` names its record by the identity exactly as typed. + const variantName = 'GitHub/Squad/workflows'; + const variant = `.github/aw/packages/github-squad-workflows-${ + createHash('sha256').update(variantName).digest('hex').slice(0, 12)}.json`; + expect(variant).toBe('.github/aw/packages/github-squad-workflows-9361b4232957.json'); + write(root, variant, readFileSync(join(root, OWNERSHIP_DESTINATION), 'utf8') + .replaceAll(`"package": "${PACKAGE_NAME}"`, `"package": "${variantName}"`)); + git(root, 'add', '--force', '--', variant, OWNERSHIP_DESTINATION); + git(root, '-c', 'user.name=Test', '-c', 'user.email=test@example.invalid', + '-c', 'core.hooksPath=/dev/null', 'commit', '--quiet', '-m', 'canonical and case-variant records'); + expect(verifyInstall(root).failures.join('\n'), `${OWNERSHIP_DESTINATION} beside committed ${variant}`) + .toContain(`case-variant record ${variant} (${variantName})`); + + rmSync(join(root, variant)); + expect(verifyInstall(root).failures, `working tree without ${variant}`).toEqual([]); + const args = [verifier, '--verify-staged-install', '--stage-ownership', '--source-revision', revision]; + const state = `${variant} (${variantName}) deleted only from the working tree, still in the index`; + const rejected = spawnSync(process.execPath, args, { cwd: root, encoding: 'utf8' }); + expect(rejected.status, `--verify-staged-install with ${state}:\n${rejected.stderr}`).toBe(1); + expect(rejected.stderr, `--verify-staged-install with ${state}`).toContain( + `Case-variant Squad package ownership record remains in staged tree: ${variant} (${variantName}); ` + + 'delete it and stage the deletion before commit/push.', + ); + + git(root, 'add', '--', '.github/aw/'); + expect(git(root, 'diff', '--cached', '--diff-filter=D', '--name-only').trim()).toBe(variant); + const accepted = spawnSync(process.execPath, args, { cwd: root, encoding: 'utf8' }); + expect(accepted.status, `--verify-staged-install after staging the deletion of ${variant}:\n${accepted.stderr}`) + .toBe(0); + + // The committed result is what a clean checkout verifies. + git(root, '-c', 'user.name=Test', '-c', 'user.email=test@example.invalid', + '-c', 'core.hooksPath=/dev/null', 'commit', '--quiet', '-m', 'delete the case-variant record'); + const clone = mkdtempSync(join(tmpdir(), 'squad-install-staging-clone-')); + roots.push(clone); + execFileSync('git', ['-c', 'core.autocrlf=false', 'clone', '--quiet', root, clone]); + expect(existsSync(join(clone, variant)), `clean checkout must not contain ${variant}`).toBe(false); + expect(verifyInstall(clone, { expectedRevision: revision }).failures, `clean checkout of ${root}`).toEqual([]); + }, 120_000); + it('moves a legacy-owned install with a staged canonical record forward instead of deleting it', () => { const root = consumer(LEGACY_PACKAGE_NAME); // The canonical record was committed earlier, then removed from the working tree. @@ -379,7 +424,8 @@ describe('gh-aw: verified ownership staging under consumer ignore rules', () => expect(legacy.status, legacy.stderr).toBe(0); expect(legacy.stdout).toContain(`Squad gh-aw installation verified at ${revision}.`); expect(legacy.stderr, `a ${LEGACY_PACKAGE_NAME} install must print the move notice`).toContain([ - `Notice: verified under the legacy ${LEGACY_PACKAGE_NAME} package identity, which remains supported.`, + `Notice: verified under the legacy ${LEGACY_PACKAGE_NAME} package identity, accepted only so that existing ` + + 'installations can move; bradygaster/squad is now a separately controlled fork.', `To move to ${PACKAGE_NAME}, follow the gh-aw guide's Upgrading steps, starting with:`, ` gh aw add ${PACKAGE_NAME}@${revision} --force &&`, ` rm -f ${LEGACY_OWNERSHIP}`, diff --git a/workflows/README.md b/workflows/README.md index 3fd4d7f50..3712a9af6 100644 --- a/workflows/README.md +++ b/workflows/README.md @@ -11,11 +11,12 @@ gh aw add github/squad/workflows@<40-character-commit-sha> `workflows/squad-workflows.manifest.json` declares `"package": "github/squad/workflows"`. Earlier revisions, including ones committed after the repository moved to `github/squad`, declare and verify only -the legacy identity `bradygaster/squad/workflows`: install an older approved -revision under that identity, or ask the maintainers for a newer revision. -Revisions that declare `github/squad/workflows` still verify legacy -installations. When moving an existing installation to the new identity, -reinstall with `--force` and delete the superseded +the legacy identity `bradygaster/squad/workflows`, and their activation +downloads the Squad CLI from the separately controlled `bradygaster/squad` fork, +which has no releases. Do not install them: use a revision that declares +`github/squad/workflows`, or ask the maintainers for one. The verifier of such a +revision still accepts the legacy identity so that existing installations can +move. To move one, reinstall with `--force` and delete the superseded `.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json` record that gh-aw leaves behind. diff --git a/workflows/package/squad-bootstrap.md b/workflows/package/squad-bootstrap.md index 23ef32e9b..0443ad6d0 100644 --- a/workflows/package/squad-bootstrap.md +++ b/workflows/package/squad-bootstrap.md @@ -153,7 +153,7 @@ pre-agent-steps: node --check "$path" >/dev/null } # BEGIN GENERATED RESOURCE DIGESTS - check_hash "$install_verifier" "c3416f68efee474790165d36e5dd9903430753cf1d710868dad0ee0834e5146b" + check_hash "$install_verifier" "2ac6645565c662650ffd9e06a2021ffb123f4f96172a00abb6ed57ece5267fa0" check_hash "$cast_validator" "15f0d47917d269b5b967dc2a782a0eb761c39af7a92ddb64fed687a427070760" check_hash "$bootstrap_validator" "d552d041f006249bfaa67b10a75877f813796b7698c718ac7eb7cf16f3a72e31" # END GENERATED RESOURCE DIGESTS diff --git a/workflows/shared/squad-install-verifier.mjs b/workflows/shared/squad-install-verifier.mjs index ab851a441..9686fef02 100644 --- a/workflows/shared/squad-install-verifier.mjs +++ b/workflows/shared/squad-install-verifier.mjs @@ -134,6 +134,11 @@ function assertAcceptedPackageName(packageName) { } } +function isCaseVariantPackageName(packageName) { + return typeof packageName === 'string' && !ACCEPTED_PACKAGE_NAMES.includes(packageName) + && ACCEPTED_PACKAGE_NAMES.some(accepted => accepted.toLowerCase() === packageName.toLowerCase()); +} + // Mirrors gh-aw v0.91.5 stablePackageID: lowercase slug plus the first 12 hex digits of // SHA-256 over the package identity exactly as installed. export function packageOwnershipDestination(packageName) { @@ -777,8 +782,7 @@ function findOwnershipRecord(root) { } if (ACCEPTED_PACKAGE_NAMES.includes(record?.package)) { matches.push({ path: relativePath, record }); - } else if (typeof record?.package === 'string' && ACCEPTED_PACKAGE_NAMES - .some(accepted => accepted.toLowerCase() === record.package.toLowerCase())) { + } else if (isCaseVariantPackageName(record?.package)) { caseVariants.push({ path: relativePath, record }); } } @@ -1116,10 +1120,13 @@ export function verifyStagedInstall(root, { expectedRevision = '', stageOwnershi } // Snapshot the index, not HEAD or the working tree, including unchanged tracked files. const tree = spawnChecked('git', ['write-tree'], root).stdout.trim(); - // Exact allowlisted paths bound output; no recursion into unrelated or substituted trees. + // Exact allowlisted paths bound output; no recursion into unrelated or substituted trees. The + // ownership-record directory is listed one level deep: a case variant's record name hashes the + // identity as typed, so its path cannot be predicted. const entries = spawnChecked( 'git', - ['ls-tree', '-z', tree, '--', ...required, REPO_CONFIG_PATH, UNOWNED_MUTABLE_ROUTER_SKILL, ...superseded], + ['ls-tree', '-z', tree, '--', ...required, REPO_CONFIG_PATH, UNOWNED_MUTABLE_ROUTER_SKILL, ...superseded, + '.github/aw/packages/'], root, ).stdout .split('\0').filter(Boolean); @@ -1149,7 +1156,7 @@ export function verifyStagedInstall(root, { expectedRevision = '', stageOwnershi } for (const path of superseded) { if (!staged.has(path)) continue; - // The skill's deletion gate permits deleting only legacy records, so a staged canonical + // The skill's deletion gate never permits deleting the canonical record, so a staged canonical // record in a legacy-owned install must move forward instead of being deleted. throw new Error(path === OWNERSHIP_DESTINATION ? `Squad package ownership record ${path} (${PACKAGE_NAME}) remains in the staged tree of this ` @@ -1159,6 +1166,24 @@ export function verifyStagedInstall(root, { expectedRevision = '', stageOwnershi : `Superseded Squad package ownership record remains in staged tree: ${path}; ` + 'delete it and stage the deletion before commit/push.'); } + // A record deleted only from the working tree still reaches a clean checkout, where + // findOwnershipRecord rejects a case variant beside the verified record. + for (const [path, [, type, object]] of staged) { + if (type !== 'blob' || required.has(path) || !/^\.github\/aw\/packages\/[^/]+\.json$/.test(path)) continue; + const bytes = spawnChecked('git', ['cat-file', 'blob', object], root).stdout; + let packageName; + try { + packageName = JSON.parse(bytes)?.package; + } catch { + continue; + } + if (isCaseVariantPackageName(packageName)) { + throw new Error( + `Case-variant Squad package ownership record remains in staged tree: ${path} (${packageName}); ` + + 'delete it and stage the deletion before commit/push.', + ); + } + } } catch (error) { result.failures.push(error instanceof Error ? error.message : String(error)); } @@ -1261,7 +1286,8 @@ function recoveryMessage(revision) { function legacyIdentityNotice(packageName, revision) { return [ - `Notice: verified under the legacy ${packageName} package identity, which remains supported.`, + `Notice: verified under the legacy ${packageName} package identity, accepted only so that existing ` + + `installations can move; ${packageRepository(packageName)} is now a separately controlled fork.`, `To move to ${PACKAGE_NAME}, follow the gh-aw guide's Upgrading steps, starting with:`, ...chainedCommands([ `gh aw add ${PACKAGE_NAME}@${revision} --force`, diff --git a/workflows/skills/gh-aw-enlistment/SKILL.md b/workflows/skills/gh-aw-enlistment/SKILL.md index 9839df422..c69782bac 100644 --- a/workflows/skills/gh-aw-enlistment/SKILL.md +++ b/workflows/skills/gh-aw-enlistment/SKILL.md @@ -203,7 +203,7 @@ test "${squad_package}" = "github/squad/workflows" || { echo "Check gh auth status --hostname github.com and network access, and confirm SQUAD_SHA is a github/squad commit that has this manifest, then retry." >&2 else echo "STOP: Squad ${SQUAD_SHA} does not declare the github/squad/workflows package (found '${squad_package}')." >&2 - echo "Install a revision that declares bradygaster/squad/workflows under that identity, or ask the Squad maintainers for a newer revision." >&2 + echo "Use a revision whose manifest declares github/squad/workflows, or ask the Squad maintainers for one. Older revisions download the Squad CLI from the bradygaster/squad fork, which has no releases." >&2 fi exit 1 } @@ -215,11 +215,11 @@ rm -f .github/skills/agentic-workflows/SKILL.md `github/squad/workflows` verifies only at Squad revisions whose `workflows/squad-workflows.manifest.json` declares `"package": "github/squad/workflows"`. The identity check above reads that -manifest at `SQUAD_SHA` and stops on any other revision. An older -maintainer-approved revision declares `bradygaster/squad/workflows`: install it -under that identity, because its verifier accepts no other, or ask the Squad -maintainers for a newer revision. Such a revision's safe-update report names -the same squad-init action as `bradygaster/squad/.github/actions/squad-init`. +manifest at `SQUAD_SHA` and stops on any other revision. Do not install an +older revision under `bradygaster/squad/workflows` instead: its activation +downloads the Squad CLI from the separately controlled `bradygaster/squad` +fork, which has no releases. Use a revision whose manifest declares +`github/squad/workflows`, or ask the Squad maintainers for one. The nested `workflows/aw.yml` is the only supported distribution registration. It isolates package auto-discovery from unrelated repository skills and agents, @@ -255,9 +255,10 @@ Revisions whose `workflows/squad-workflows.manifest.json` declares `bradygaster/squad/workflows` identity. Earlier revisions, including ones committed after the repository moved to `github/squad`, declare and verify only `bradygaster/squad/workflows`, and installations made from them carry it in -their ownership record, workflow `source:` lines, and locks. Upgrade such an -installation to a revision that declares `github/squad/workflows` with the block -below. gh-aw keeps the superseded +their ownership record, workflow `source:` lines, and locks. Those revisions +download the Squad CLI from the separately controlled `bradygaster/squad` fork, +which has no releases, so move such an installation to a revision that declares +`github/squad/workflows` with the block below. gh-aw keeps the superseded `.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json` after the forced reinstall, so the block deletes exactly that file before verifying; the verifier fails closed while both ownership records exist. If the forced @@ -268,13 +269,10 @@ also makes `gh aw add` report one safe-update change: action squad-init action under the repository's new owner; list it in the PR's security review note. **STOP** on any other new secret or action. Until the upgrade is committed, every strict compile repeats that change as four -warnings, which step 5 allows only for this migration. Until an -installation moves, `--verify-install` still passes and prints a notice naming -the legacy identity. To stay on a revision that declares -`bradygaster/squad/workflows`, drop the identity check below, keep the -`gh aw add` line's STOP guard but replace its command with -`gh aw add "bradygaster/squad/workflows@${SQUAD_SHA}" --force`, and skip the -second `rm -f`: that ownership record is the only one such an installation has. +warnings, which step 5 allows only for this migration. Verifiers of revisions +that declare `github/squad/workflows` still accept the legacy identity, so an +existing installation keeps verifying while it moves, and they print a notice +that says to move it. ```bash : "${SQUAD_SHA:?STOP: set SQUAD_SHA to an explicit, maintainer-approved 40-character Squad commit SHA before upgrading.}" @@ -291,7 +289,7 @@ test "${squad_package}" = "github/squad/workflows" || { echo "Check gh auth status --hostname github.com and network access, and confirm SQUAD_SHA is a github/squad commit that has this manifest, then retry." >&2 else echo "STOP: Squad ${SQUAD_SHA} does not declare the github/squad/workflows package (found '${squad_package}')." >&2 - echo "Install a revision that declares bradygaster/squad/workflows under that identity, or ask the Squad maintainers for a newer revision." >&2 + echo "Use a revision whose manifest declares github/squad/workflows, or ask the Squad maintainers for one. Older revisions download the Squad CLI from the bradygaster/squad fork, which has no releases." >&2 fi exit 1 } @@ -335,10 +333,6 @@ On a clean repo, `gh aw add` reports these expected safe updates and **nothing e - Action: **`github/squad/.github/actions/squad-init`** -At a revision whose manifest declares `bradygaster/squad/workflows`, the same -action is reported as `bradygaster/squad/.github/actions/squad-init`. Accept -that name only for such a revision. - > **These are referenced names, not prerequisites.** `gh aw add` lists the secrets > the workflows *reference* so you can approve that surface — it is not asking you > to supply them. Both secrets are optional, they need not exist, and neither is @@ -476,7 +470,23 @@ git diff --cached --stat unexpected_deletions="$( git diff --cached --diff-filter=D --name-only | grep -vxF -e '.github/skills/agentic-workflows/SKILL.md' \ - -e '.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json' || true + -e '.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json' | + while IFS= read -r path; do + # Also allowed: an ownership record whose package is a case variant, such as GitHub/Squad/workflows. + if [[ "${path}" =~ ^\.github/aw/packages/[^/]+\.json$ ]] && + git show "HEAD:${path}" | node -e ' + const accepted = ["github/squad/workflows", "bradygaster/squad/workflows"]; + let text = ""; + process.stdin.on("data", chunk => { text += chunk; }).on("end", () => { + let name; + try { name = JSON.parse(text).package; } catch {} + process.exit(typeof name === "string" && !accepted.includes(name) + && accepted.includes(name.toLowerCase()) ? 0 : 1); + });'; then + continue + fi + printf '%s\n' "${path}" + done || true )" test -z "${unexpected_deletions}" || { printf 'STOP: unexpected staged deletions:\n%s\n' "${unexpected_deletions}" >&2 @@ -486,9 +496,12 @@ test -z "${unexpected_deletions}" || { - The only permitted staged deletions are `.github/skills/agentic-workflows/SKILL.md`, when upgrading a repository that - previously committed gh-aw's mutable router, and + previously committed gh-aw's mutable router, `.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json`, when moving - an existing installation off the legacy `bradygaster/squad/workflows` identity. + an existing installation off the legacy `bradygaster/squad/workflows` identity, + and a `.github/aw/packages/*.json` ownership record whose `package` is a case + variant of `github/squad/workflows` or `bradygaster/squad/workflows`, such as + one left by an install typed as `GitHub/Squad/workflows`. - **STOP** if the staged diff shows any other **unexpected deletions**, **unexpected secrets**, edits to **unrelated files**, or committed **log/diagnostic output**. Re-scope with explicit `git add -- ` — never `git add .`, `git add -A`, or `git commit -a`. @@ -636,7 +649,7 @@ test "${squad_package}" = "github/squad/workflows" || { echo "Check gh auth status --hostname github.com and network access, and confirm SQUAD_SHA is a github/squad commit that has this manifest, then retry." >&2 else echo "STOP: Squad ${SQUAD_SHA} does not declare the github/squad/workflows package (found '${squad_package}')." >&2 - echo "Install a revision that declares bradygaster/squad/workflows under that identity, or ask the Squad maintainers for a newer revision." >&2 + echo "Use a revision whose manifest declares github/squad/workflows, or ask the Squad maintainers for one. Older revisions download the Squad CLI from the bradygaster/squad fork, which has no releases." >&2 fi exit 1 } @@ -708,9 +721,7 @@ gh pr merge --squash # auto-merge before human review. NEVER. `.github/skills/agentic-workflows/SKILL.md`; keep the exact Squad-owned `.github/skills/gh-aw-enlistment/SKILL.md`. - ❌ **Approving unknown safe updates.** Approve ONLY `SQUAD_GITHUB_APP_PRIVATE_KEY`, - `SQUAD_GITHUB_TOKEN`, and `github/squad/.github/actions/squad-init` (reported - as `bradygaster/squad/.github/actions/squad-init` only at a revision whose - manifest declares `bradygaster/squad/workflows`). Anything + `SQUAD_GITHUB_TOKEN`, and `github/squad/.github/actions/squad-init`. Anything else is a STOP. - ❌ **Treating `--approve` as the final compile.** Always finish with a plain `gh aw compile --strict` (no `--approve`). diff --git a/workflows/squad-bootstrap.md b/workflows/squad-bootstrap.md index abcc75f17..46c04d812 100644 --- a/workflows/squad-bootstrap.md +++ b/workflows/squad-bootstrap.md @@ -160,7 +160,7 @@ pre-agent-steps: node --check "$path" >/dev/null } # BEGIN GENERATED RESOURCE DIGESTS - check_hash "$install_verifier" "c3416f68efee474790165d36e5dd9903430753cf1d710868dad0ee0834e5146b" + check_hash "$install_verifier" "2ac6645565c662650ffd9e06a2021ffb123f4f96172a00abb6ed57ece5267fa0" check_hash "$cast_validator" "15f0d47917d269b5b967dc2a782a0eb761c39af7a92ddb64fed687a427070760" check_hash "$bootstrap_validator" "d552d041f006249bfaa67b10a75877f813796b7698c718ac7eb7cf16f3a72e31" # END GENERATED RESOURCE DIGESTS diff --git a/workflows/squad-workflows.manifest.json b/workflows/squad-workflows.manifest.json index fd09c64a1..7d01f9627 100644 --- a/workflows/squad-workflows.manifest.json +++ b/workflows/squad-workflows.manifest.json @@ -69,9 +69,9 @@ "source": "workflows/package/squad-bootstrap.md", "destination": ".github/workflows/squad-bootstrap.md", "lock": ".github/workflows/squad-bootstrap.lock.yml", - "source_sha256": "1759f8c06533572c2282bafd63a87d33a7c9fe5105738de7a785b5b42853cd5d", - "lock_sha256": "6859004e8d6e0f3a61fdf7398c77797f5c06ab0a431807e39bd4c36348d576e5", - "package_lock_sha256": "95256d66b3ad79ee13b800a251df09cdd6bfa20ea12d8ca1eda5ddaeaefc80d9" + "source_sha256": "c230742d8db6fc4b9be687e0a1cfe5d36bfd1b0b8609b0ef0798c43681bb7ff2", + "lock_sha256": "bacf5c2bf4bf34c7a8315aabf3efcd66e4fa1fe493adf8db929c1e58b8c1fd12", + "package_lock_sha256": "2113e1fa297563ea531c2413943ee43e41590723a93b62eeeec932d0f1650d42" }, { "name": "squad-command-router", @@ -119,8 +119,8 @@ }, { "name": "squad-bootstrap", - "lock_sha256": "c6e6987f079d5fbfcee27020280210310630c03d599a9bbd566497e1e3b5292c", - "package_lock_sha256": "1eab03ef2d65d94118f3c9f171e8118299bee422bca9f2a70358e1c56da85894" + "lock_sha256": "50886640b68360eee84cdb7871dfb789e2ac6b71c86d3c7b4a5cea6774c3dc49", + "package_lock_sha256": "e14857df6cac03ac0ba997cc39d6caf2dac2b2f75c58f74c18a3473abc84f4c4" }, { "name": "squad-command-router", @@ -169,7 +169,7 @@ "package_destination": ".github/workflows/shared/squad-install-verifier.mjs", "destination": ".github/workflows/shared/squad-install-verifier.mjs", "ownership": "manifest", - "sha256": "c3416f68efee474790165d36e5dd9903430753cf1d710868dad0ee0834e5146b" + "sha256": "2ac6645565c662650ffd9e06a2021ffb123f4f96172a00abb6ed57ece5267fa0" }, { "path": "shared/squad-command-contract.mjs", @@ -305,7 +305,7 @@ "name": "gh-aw-enlistment", "source": "workflows/skills/gh-aw-enlistment/SKILL.md", "destination": ".github/skills/gh-aw-enlistment/SKILL.md", - "sha256": "e42652883653289ea5cecb69d51fbb7b6bbd372514666a09cff5920a492c01a8" + "sha256": "7efe18f78bd8c2bd9ea060fd049ca6de2389f961cac5d3295f8ad83dc77596bd" } ], "bootstrap": { From 8cba4d9d7d89ce5b1ab9e4549487a0eb9be8cff5 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Mon, 9 Nov 2026 02:58:04 +0200 Subject: [PATCH 5/6] fix(gh-aw): pass repository in squad-init examples, move ownership hints Pinned squad-init revisions from before the action's default changed, such as 5c662ba, still download the Squad CLI and its checksum from bradygaster/squad, now a separately controlled fork. Changing the action owner in a usage example does not change that default. Pass repository: github/squad in the standalone-install example and in the action.yml usage comment, the two examples that lacked it; the package workflows already pass it. A new test scans every squad-init step in tracked Markdown and YAML, including YAML comments, and names the file:line of any that does not pass repository: github/squad. The verifier's ownership diagnostics could steer users back to the legacy identity. With an exact record beside a case-variant record, it said to reinstall as the exact record's identity, which may be bradygaster/squad/workflows. It now always recommends github/squad/workflows and lists a legacy exact record for deletion together with the case variants; a canonical exact record is kept. With both exact identities present, it no longer offers to keep whichever record matches the installed source bindings, which keeps the legacy record while those bindings are still legacy. Regenerate the integrity manifest and bootstrap digests for the verifier change. The case-variant test now covers both the canonical and the legacy exact record. Refs #2403 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/actions/squad-init/action.yml | 1 + .../docs/features/standalone-install.md | 3 +- test/gh-aw-quality.test.ts | 27 ++++++--- test/squad-cli-latest.test.ts | 56 +++++++++++++++++++ workflows/package/squad-bootstrap.md | 2 +- workflows/shared/squad-install-verifier.mjs | 11 ++-- workflows/squad-bootstrap.md | 2 +- workflows/squad-workflows.manifest.json | 12 ++-- 8 files changed, 91 insertions(+), 23 deletions(-) diff --git a/.github/actions/squad-init/action.yml b/.github/actions/squad-init/action.yml index 235b6f2cc..e2cfb5b8f 100644 --- a/.github/actions/squad-init/action.yml +++ b/.github/actions/squad-init/action.yml @@ -14,6 +14,7 @@ # version: 'v0.11.0' # default: latest release # preset: 'default' # default: default # state-backend: 'local' # default: local +# repository: 'github/squad' # older pins default to the bradygaster/squad fork # # Every input is optional; the defaults match what the gh-aw shared component # runs today. diff --git a/docs/src/content/docs/features/standalone-install.md b/docs/src/content/docs/features/standalone-install.md index 83e537d87..eb5c6b9b1 100644 --- a/docs/src/content/docs/features/standalone-install.md +++ b/docs/src/content/docs/features/standalone-install.md @@ -133,9 +133,10 @@ The `squad-init` action wraps the install and init steps: ```yaml - uses: github/squad/.github/actions/squad-init@ with: - version: v0.13.1 # default: latest release + version: v0.13.1 # default: latest release preset: default state-backend: local + repository: github/squad # older pins default to the bradygaster/squad fork ``` Point `repository:` at an internal mirror if your runners cannot reach diff --git a/test/gh-aw-quality.test.ts b/test/gh-aw-quality.test.ts index b53fedc98..3d1cd937e 100644 --- a/test/gh-aw-quality.test.ts +++ b/test/gh-aw-quality.test.ts @@ -4811,7 +4811,10 @@ describe('gh-aw: canonical package integrity contract', () => { const state = `${OWNERSHIP_FIXTURE} (${PACKAGE_NAME}) and ${legacyPath} (${LEGACY_PACKAGE_NAME}) both present`; expect(failure, state).toContain('Expected exactly one Squad package ownership record; found 2'); expect(failure, state).toContain(`${legacyPath} (${LEGACY_PACKAGE_NAME})`); - expect(failure, state).toContain(`after reinstalling as ${PACKAGE_NAME}, delete ${legacyPath}.`); + expect(failure, `${state}: the guidance must move to ${PACKAGE_NAME}`) + .toContain(`. After reinstalling as ${PACKAGE_NAME}, delete ${legacyPath}.`); + expect(failure, `${state}: the guidance must not offer keeping the ${LEGACY_PACKAGE_NAME} record`) + .not.toContain('Keep only the record whose identity matches the installed workflow source bindings'); unlinkSync(join(root, legacyPath)); expect(verifyInstall(root, { expectedRevision: revisionA }).failures).toEqual([]); }); @@ -4859,25 +4862,31 @@ describe('gh-aw: canonical package integrity contract', () => { .not.toContain('exactly as typed'); }); - it('rejects a case-variant ownership record left beside the exact record', () => { - // gh-aw names each record by a digest of the identity as typed, so reinstalling a - // GitHub/Squad/workflows install as github/squad/workflows leaves both records. + it.each([ + ['canonical', PACKAGE_NAME, 'workflow'], + ['legacy', LEGACY_PACKAGE_NAME, 'package'], + ] as const)('rejects a case-variant ownership record left beside the exact %s record', (label, exactName, binding) => { + // gh-aw names each record by a digest of the identity as typed, so reinstalling as + // GitHub/Squad/workflows over an exact install leaves both records. const variant = 'GitHub/Squad/workflows'; const variantPath = '.github/aw/packages/github-squad-workflows-' + `${createHash('sha256').update(variant).digest('hex').slice(0, 12)}.json`; - const root = makeConsumer(); + const root = makeConsumer(revisionA, true, binding, exactName); const record = JSON.parse(readText(join(root, OWNERSHIP_FIXTURE))); writeFileSync(join(root, variantPath), `${JSON.stringify({ ...record, package: variant, source: `${variant}@${revisionA}`, }, null, 2)}\n`); - const state = `${OWNERSHIP_FIXTURE} (${PACKAGE_NAME}) beside ${variantPath} (${variant})`; + // Squad always moves to PACKAGE_NAME, so an exact legacy record is superseded too. + const stale = exactName === PACKAGE_NAME ? [variantPath] : [OWNERSHIP_FIXTURE, variantPath]; + const state = `${label} ${OWNERSHIP_FIXTURE} (${exactName}) beside ${variantPath} (${variant})`; const failure = verifyInstall(root, { expectedRevision: revisionA }).failures.join('\n'); expect(failure, `${state} must fail verification`).toContain( - `Expected exactly one Squad package ownership record; found ${OWNERSHIP_FIXTURE} (${PACKAGE_NAME}) ` + `Expected exactly one Squad package ownership record; found ${OWNERSHIP_FIXTURE} (${exactName}) ` + `and case-variant record ${variantPath} (${variant}), so more than one record claims the installed files.`, ); - expect(failure, `${state} must name the record to delete`) - .toContain(`After reinstalling as ${PACKAGE_NAME}, delete ${variantPath}.`); + expect(failure, `${state} must recommend ${PACKAGE_NAME} and name every record to delete`) + .toContain(`After reinstalling as ${PACKAGE_NAME}, delete ${stale.join(', ')}.`); + if (exactName !== PACKAGE_NAME) return; unlinkSync(join(root, variantPath)); expect(verifyInstall(root, { expectedRevision: revisionA }).failures, `${state} after deleting ${variantPath}`) .toEqual([]); diff --git a/test/squad-cli-latest.test.ts b/test/squad-cli-latest.test.ts index e85dcbf28..4a643d83b 100644 --- a/test/squad-cli-latest.test.ts +++ b/test/squad-cli-latest.test.ts @@ -27,6 +27,46 @@ function activationInput(name: string): string { return String(steps[0].with?.[name] ?? action.inputs[name].default); } +/** + * Every `uses: //.github/actions/squad-init@` step in tracked docs, skills, templates, + * workflows and YAML comments, with the `repository` its `with:` block passes. + */ +function squadInitExamples(): { where: string; repository: string | null }[] { + const tracked = spawnSync('git', ['ls-files', '-z', '--', '*.md', '*.mdx', '*.yml', '*.yaml'], { + cwd: process.cwd(), + encoding: 'utf8', + }); + expect(tracked.status, `git ls-files: ${tracked.stderr}`).toBe(0); + const examples: { where: string; repository: string | null }[] = []; + for (const path of tracked.stdout.split('\0').filter(path => path && !path.startsWith('test/'))) { + const lines = read(path).split('\n'); + lines.forEach((line, index) => { + const uses = /^(\s*#?)\s*(?:-\s+)?uses:\s*['"]?[\w.-]+\/[\w.-]+\/\.github\/actions\/squad-init@/.exec(line); + if (!uses) return; + // An example inside YAML comments keeps the same `#` prefix on every line. + const comment = uses[1].endsWith('#') ? uses[1] : ''; + const body = (text: string) => (text.startsWith(comment) ? text.slice(comment.length) : null); + const keyColumn = body(line)!.indexOf('uses:'); + let inWith = false; + let repository: string | null = null; + for (const next of lines.slice(index + 1)) { + const text = body(next); + if (text === null || !text.trim() || text.trim().startsWith('```')) break; + const column = text.search(/\S/); + if (column < keyColumn) break; + if (column === keyColumn) { + inWith = /^with:\s*(?:#.*)?$/.test(text.trim()); + continue; + } + const value = inWith ? /^repository:\s*['"]?([^'"\s#]+)['"]?\s*(?:#.*)?$/.exec(text.trim()) : null; + if (value) repository = value[1]; + } + examples.push({ where: `${path}:${index + 1}`, repository }); + }); + } + return examples; +} + function pinnedInstallShell(platform: NodeJS.Platform = process.platform): string { const install = action.runs.steps.find((step: { id?: string }) => step.id === 'install'); expect(install).toBeDefined(); @@ -172,6 +212,22 @@ describe('Squad standalone activation latest-release default', () => { ).toBe('github/squad'); }); + it('passes repository: github/squad in every squad-init example a reader could pin to an older revision', () => { + const examples = squadInitExamples(); + expect([...new Set(examples.map(({ where }) => where.replace(/:\d+$/, '')))], 'files with squad-init examples') + .toEqual(expect.arrayContaining([ + '.github/actions/squad-init/action.yml', + 'docs/src/content/docs/features/standalone-install.md', + 'workflows/shared/squad.md', + ])); + expect( + examples.filter(({ repository }) => repository !== 'github/squad') + .map(({ where, repository }) => `${where}: repository ${repository ?? 'not passed'}`), + `squad-init examples without repository: github/squad; squad-init@${revision} defaults it to ` + + `${action.inputs.repository.default}`, + ).toEqual([]); + }); + it('executes the pinned action latest default with a verified platform archive', () => { const result = runPinnedInstall('valid'); expect( diff --git a/workflows/package/squad-bootstrap.md b/workflows/package/squad-bootstrap.md index 0443ad6d0..085847095 100644 --- a/workflows/package/squad-bootstrap.md +++ b/workflows/package/squad-bootstrap.md @@ -153,7 +153,7 @@ pre-agent-steps: node --check "$path" >/dev/null } # BEGIN GENERATED RESOURCE DIGESTS - check_hash "$install_verifier" "2ac6645565c662650ffd9e06a2021ffb123f4f96172a00abb6ed57ece5267fa0" + check_hash "$install_verifier" "df0cf0796cf5d2e30a182f425279d6e849f2147f24077def7fda61655cd8358d" check_hash "$cast_validator" "15f0d47917d269b5b967dc2a782a0eb761c39af7a92ddb64fed687a427070760" check_hash "$bootstrap_validator" "d552d041f006249bfaa67b10a75877f813796b7698c718ac7eb7cf16f3a72e31" # END GENERATED RESOURCE DIGESTS diff --git a/workflows/shared/squad-install-verifier.mjs b/workflows/shared/squad-install-verifier.mjs index 9686fef02..9a5eac0f9 100644 --- a/workflows/shared/squad-install-verifier.mjs +++ b/workflows/shared/squad-install-verifier.mjs @@ -791,19 +791,20 @@ function findOwnershipRecord(root) { throw new Error( `Expected exactly one Squad package ownership record; found ${matches.length}: ` + `${describeRecords(matches)}. ` - + 'Keep only the record whose identity matches the installed workflow source bindings; ' - + `after reinstalling as ${PACKAGE_NAME}, delete ${LEGACY_OWNERSHIP_DESTINATIONS.join(', ')}.`, + + `After reinstalling as ${PACKAGE_NAME}, delete ${LEGACY_OWNERSHIP_DESTINATIONS.join(', ')}.`, ); } if (matches.length === 1 && caseVariants.length > 0) { // gh-aw keys records by the identity exactly as typed, so a case-only reinstall keeps the old record. - const [{ record }] = matches; + // Always move to PACKAGE_NAME; a legacy exact record is then superseded as well. + const [match] = matches; + const stale = [...(match.record.package === PACKAGE_NAME ? [] : [match]), ...caseVariants]; throw new Error( `Expected exactly one Squad package ownership record; found ${describeRecords(matches)} and case-variant ` + `${caseVariants.length === 1 ? 'record' : 'records'} ${describeRecords(caseVariants)}, ` + 'so more than one record claims the installed files. gh-aw records the package identity exactly as typed ' - + `and Squad matches it case-sensitively. After reinstalling as ${record.package}, delete ` - + `${caseVariants.map(({ path }) => path).join(', ')}.`, + + `and Squad matches it case-sensitively. After reinstalling as ${PACKAGE_NAME}, delete ` + + `${stale.map(({ path }) => path).join(', ')}.`, ); } if (matches.length !== 1) { diff --git a/workflows/squad-bootstrap.md b/workflows/squad-bootstrap.md index 46c04d812..b2e8fc410 100644 --- a/workflows/squad-bootstrap.md +++ b/workflows/squad-bootstrap.md @@ -160,7 +160,7 @@ pre-agent-steps: node --check "$path" >/dev/null } # BEGIN GENERATED RESOURCE DIGESTS - check_hash "$install_verifier" "2ac6645565c662650ffd9e06a2021ffb123f4f96172a00abb6ed57ece5267fa0" + check_hash "$install_verifier" "df0cf0796cf5d2e30a182f425279d6e849f2147f24077def7fda61655cd8358d" check_hash "$cast_validator" "15f0d47917d269b5b967dc2a782a0eb761c39af7a92ddb64fed687a427070760" check_hash "$bootstrap_validator" "d552d041f006249bfaa67b10a75877f813796b7698c718ac7eb7cf16f3a72e31" # END GENERATED RESOURCE DIGESTS diff --git a/workflows/squad-workflows.manifest.json b/workflows/squad-workflows.manifest.json index 7d01f9627..a4d5e079b 100644 --- a/workflows/squad-workflows.manifest.json +++ b/workflows/squad-workflows.manifest.json @@ -69,9 +69,9 @@ "source": "workflows/package/squad-bootstrap.md", "destination": ".github/workflows/squad-bootstrap.md", "lock": ".github/workflows/squad-bootstrap.lock.yml", - "source_sha256": "c230742d8db6fc4b9be687e0a1cfe5d36bfd1b0b8609b0ef0798c43681bb7ff2", - "lock_sha256": "bacf5c2bf4bf34c7a8315aabf3efcd66e4fa1fe493adf8db929c1e58b8c1fd12", - "package_lock_sha256": "2113e1fa297563ea531c2413943ee43e41590723a93b62eeeec932d0f1650d42" + "source_sha256": "1757013672276715d00b5597bf1ba9977ac2c45123f20f129ebaa5337bf1a82a", + "lock_sha256": "ff537d2faf67c85922e039f7a6770aadaaab6244d6d0c57c4941dd82f1c67758", + "package_lock_sha256": "3e5749f375785c9e0e31960df601a3e0a216d0cfcd6880c5474af4e144f66346" }, { "name": "squad-command-router", @@ -119,8 +119,8 @@ }, { "name": "squad-bootstrap", - "lock_sha256": "50886640b68360eee84cdb7871dfb789e2ac6b71c86d3c7b4a5cea6774c3dc49", - "package_lock_sha256": "e14857df6cac03ac0ba997cc39d6caf2dac2b2f75c58f74c18a3473abc84f4c4" + "lock_sha256": "abba2f4eb7406942929f9d365f1c4b3d99c1764fc5ca2b2719b5428893833c7f", + "package_lock_sha256": "7b25f86c8e5aad5eceb34773eec25804ad104e0a65eb57bc1fed40a7243f27ef" }, { "name": "squad-command-router", @@ -169,7 +169,7 @@ "package_destination": ".github/workflows/shared/squad-install-verifier.mjs", "destination": ".github/workflows/shared/squad-install-verifier.mjs", "ownership": "manifest", - "sha256": "2ac6645565c662650ffd9e06a2021ffb123f4f96172a00abb6ed57ece5267fa0" + "sha256": "df0cf0796cf5d2e30a182f425279d6e849f2147f24077def7fda61655cd8358d" }, { "path": "shared/squad-command-contract.mjs", From a83f38db2b22ebd32f0c0563882ce0d9ae3f4fa2 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Mon, 9 Nov 2026 04:06:51 +0200 Subject: [PATCH 6/6] fix(gh-aw): one cleanup rule for extra Squad ownership records When canonical, legacy and case-variant ownership records coexisted, the verifier's advice left out the case variant, so following it still failed verification. A duplicate exact-identity record at a path gh-aw does not write got the computed legacy path instead of its own. findOwnershipRecord now applies one rule whenever more than one Squad record is found: it lists every record with its path and package, then says to delete every listed path except .github/aw/packages/github-squad-workflows-23a7638f599e.json after reinstalling as github/squad/workflows, because that reinstall writes exactly that record. The case-only hint uses the same rule, and records are read in sorted order so the advice is stable on every platform. The staging gate matches the advice. The skill's step-7 gate, its four mirrors and both guide gates replace the separate legacy-path allowance and the case-variant predicate with one rule. A staged deletion of .github/aw/packages/.json passes only if it is not the canonical destination, its HEAD content is JSON, and its package, lowercased, is github/squad/workflows or bradygaster/squad/workflows. The router allowance is unchanged. --verify-staged-install now rejects any still-indexed Squad record other than the verified one, not only case variants. Regenerate the integrity manifest and bootstrap verifier digests. Refs #2403 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../repo-move-github-squad-gh-aw-identity.md | 2 +- .../skills/gh-aw-enlistment/SKILL.md | 23 +-- docs/src/content/docs/guide/gh-aw.md | 32 ++--- .../skills/gh-aw-enlistment/SKILL.md | 23 +-- .../skills/gh-aw-enlistment/SKILL.md | 23 +-- templates/skills/gh-aw-enlistment/SKILL.md | 23 +-- test/gh-aw-enlistment-skill.test.ts | 100 ++++++++----- test/gh-aw-install-staging.test.ts | 46 +++--- test/gh-aw-quality.test.ts | 136 ++++++++++++++---- workflows/package/squad-bootstrap.md | 2 +- workflows/shared/squad-install-verifier.mjs | 55 +++---- workflows/skills/gh-aw-enlistment/SKILL.md | 23 +-- workflows/squad-bootstrap.md | 2 +- workflows/squad-workflows.manifest.json | 14 +- 14 files changed, 316 insertions(+), 188 deletions(-) diff --git a/.changeset/repo-move-github-squad-gh-aw-identity.md b/.changeset/repo-move-github-squad-gh-aw-identity.md index 738a1c297..97b29ddd4 100644 --- a/.changeset/repo-move-github-squad-gh-aw-identity.md +++ b/.changeset/repo-move-github-squad-gh-aw-identity.md @@ -3,4 +3,4 @@ '@bradygaster/squad-sdk': patch --- -Update the shipped gh-aw-enlistment skill for the repository's new home, `github/squad`. It now installs the gh-aw package as `github/squad/workflows`, links to the docs guide's new address, and allowlists the `github/squad/.github/actions/squad-init` action. Before installing or upgrading, it checks that the chosen Squad revision declares `github/squad/workflows` in its package manifest, and stops otherwise. It no longer suggests installing an older revision as `bradygaster/squad/workflows`, because such revisions download the Squad CLI from the separately controlled `bradygaster/squad` fork. It also explains how to move an installation off that legacy identity, which the install verifier accepts only so that existing installations can move. That upgrade stops before removing anything if the forced reinstall fails, and until it is committed, its strict compile gate accepts only the four expected squad-init owner-replacement warnings. Its staging gate also permits deleting a case-variant ownership record, such as one left by an install typed as `GitHub/Squad/workflows`. +Update the shipped gh-aw-enlistment skill for the repository's new home, `github/squad`. It now installs the gh-aw package as `github/squad/workflows`, links to the docs guide's new address, and allowlists the `github/squad/.github/actions/squad-init` action. Before installing or upgrading, it checks that the chosen Squad revision declares `github/squad/workflows` in its package manifest, and stops otherwise. It no longer suggests installing an older revision as `bradygaster/squad/workflows`, because such revisions download the Squad CLI from the separately controlled `bradygaster/squad` fork. It also explains how to move an installation off that legacy identity, which the install verifier accepts only so that existing installations can move. That upgrade stops before removing anything if the forced reinstall fails, and until it is committed, its strict compile gate accepts only the four expected squad-init owner-replacement warnings. Its staging gate also permits deleting any other Squad ownership record the install verifier tells you to delete, such as a duplicate or case-variant record, but never the `github/squad/workflows` record that gh-aw writes. diff --git a/.squad-templates/skills/gh-aw-enlistment/SKILL.md b/.squad-templates/skills/gh-aw-enlistment/SKILL.md index c69782bac..3fafef0ff 100644 --- a/.squad-templates/skills/gh-aw-enlistment/SKILL.md +++ b/.squad-templates/skills/gh-aw-enlistment/SKILL.md @@ -469,19 +469,18 @@ node .github/workflows/shared/squad-install-verifier.mjs \ git diff --cached --stat unexpected_deletions="$( git diff --cached --diff-filter=D --name-only | - grep -vxF -e '.github/skills/agentic-workflows/SKILL.md' \ - -e '.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json' | + grep -vxF -e '.github/skills/agentic-workflows/SKILL.md' | while IFS= read -r path; do - # Also allowed: an ownership record whose package is a case variant, such as GitHub/Squad/workflows. + # Also allowed: any Squad ownership record except the one gh-aw writes for github/squad/workflows. if [[ "${path}" =~ ^\.github/aw/packages/[^/]+\.json$ ]] && + [ "${path}" != '.github/aw/packages/github-squad-workflows-23a7638f599e.json' ] && git show "HEAD:${path}" | node -e ' const accepted = ["github/squad/workflows", "bradygaster/squad/workflows"]; let text = ""; process.stdin.on("data", chunk => { text += chunk; }).on("end", () => { let name; try { name = JSON.parse(text).package; } catch {} - process.exit(typeof name === "string" && !accepted.includes(name) - && accepted.includes(name.toLowerCase()) ? 0 : 1); + process.exit(typeof name === "string" && accepted.includes(name.toLowerCase()) ? 0 : 1); });'; then continue fi @@ -496,12 +495,14 @@ test -z "${unexpected_deletions}" || { - The only permitted staged deletions are `.github/skills/agentic-workflows/SKILL.md`, when upgrading a repository that - previously committed gh-aw's mutable router, - `.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json`, when moving - an existing installation off the legacy `bradygaster/squad/workflows` identity, - and a `.github/aw/packages/*.json` ownership record whose `package` is a case - variant of `github/squad/workflows` or `bradygaster/squad/workflows`, such as - one left by an install typed as `GitHub/Squad/workflows`. + previously committed gh-aw's mutable router, and a `.github/aw/packages/*.json` + ownership record whose `package` is `github/squad/workflows` or + `bradygaster/squad/workflows` in any letter case, other than + `.github/aw/packages/github-squad-workflows-23a7638f599e.json`, the record gh-aw + writes for `github/squad/workflows`. Those are the records the verifier tells + you to delete, such as the superseded + `.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json` when moving + an existing installation off the legacy `bradygaster/squad/workflows` identity. - **STOP** if the staged diff shows any other **unexpected deletions**, **unexpected secrets**, edits to **unrelated files**, or committed **log/diagnostic output**. Re-scope with explicit `git add -- ` — never `git add .`, `git add -A`, or `git commit -a`. diff --git a/docs/src/content/docs/guide/gh-aw.md b/docs/src/content/docs/guide/gh-aw.md index 7724072b6..48f8f5721 100644 --- a/docs/src/content/docs/guide/gh-aw.md +++ b/docs/src/content/docs/guide/gh-aw.md @@ -156,19 +156,18 @@ node .github/workflows/shared/squad-install-verifier.mjs \ git diff --cached --stat unexpected_deletions="$( git diff --cached --diff-filter=D --name-only | - grep -vxF -e '.github/skills/agentic-workflows/SKILL.md' \ - -e '.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json' | + grep -vxF -e '.github/skills/agentic-workflows/SKILL.md' | while IFS= read -r path; do - # Also allowed: an ownership record whose package is a case variant, such as GitHub/Squad/workflows. + # Also allowed: any Squad ownership record except the one gh-aw writes for github/squad/workflows. if [[ "${path}" =~ ^\.github/aw/packages/[^/]+\.json$ ]] && + [ "${path}" != '.github/aw/packages/github-squad-workflows-23a7638f599e.json' ] && git show "HEAD:${path}" | node -e ' const accepted = ["github/squad/workflows", "bradygaster/squad/workflows"]; let text = ""; process.stdin.on("data", chunk => { text += chunk; }).on("end", () => { let name; try { name = JSON.parse(text).package; } catch {} - process.exit(typeof name === "string" && !accepted.includes(name) - && accepted.includes(name.toLowerCase()) ? 0 : 1); + process.exit(typeof name === "string" && accepted.includes(name.toLowerCase()) ? 0 : 1); });'; then continue fi @@ -814,19 +813,18 @@ node .github/workflows/shared/squad-install-verifier.mjs \ git diff --cached --stat unexpected_deletions="$( git diff --cached --diff-filter=D --name-only | - grep -vxF -e '.github/skills/agentic-workflows/SKILL.md' \ - -e '.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json' | + grep -vxF -e '.github/skills/agentic-workflows/SKILL.md' | while IFS= read -r path; do - # Also allowed: an ownership record whose package is a case variant, such as GitHub/Squad/workflows. + # Also allowed: any Squad ownership record except the one gh-aw writes for github/squad/workflows. if [[ "${path}" =~ ^\.github/aw/packages/[^/]+\.json$ ]] && + [ "${path}" != '.github/aw/packages/github-squad-workflows-23a7638f599e.json' ] && git show "HEAD:${path}" | node -e ' const accepted = ["github/squad/workflows", "bradygaster/squad/workflows"]; let text = ""; process.stdin.on("data", chunk => { text += chunk; }).on("end", () => { let name; try { name = JSON.parse(text).package; } catch {} - process.exit(typeof name === "string" && !accepted.includes(name) - && accepted.includes(name.toLowerCase()) ? 0 : 1); + process.exit(typeof name === "string" && accepted.includes(name.toLowerCase()) ? 0 : 1); });'; then continue fi @@ -856,13 +854,15 @@ the complete generated diff in the bootstrap PR, address Copilot review feedback, and wait for required checks. Merge only after human approval. The only permitted staged deletions are the exact router deletion above, for an -upgrade from a repository that previously committed it, the superseded +upgrade from a repository that previously committed it, and a +`.github/aw/packages/*.json` ownership record whose `package` is +`github/squad/workflows` or `bradygaster/squad/workflows` in any letter case, +other than `.github/aw/packages/github-squad-workflows-23a7638f599e.json`, the +record gh-aw writes for `github/squad/workflows`. Those are the records the +verifier tells you to delete, such as the superseded `.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json` ownership -record, for an installation moving off the legacy `bradygaster/squad/workflows` -identity (see Upgrading), and a `.github/aw/packages/*.json` ownership record -whose `package` is a case variant of `github/squad/workflows` or -`bradygaster/squad/workflows`, such as one left by an install typed as -`GitHub/Squad/workflows`. Any other deletion is a hard stop. +record for an installation moving off the legacy `bradygaster/squad/workflows` +identity (see Upgrading). Any other deletion is a hard stop. Consumer ignore rules such as `packages/` can silently omit the required `.github/aw/packages/` ownership JSON from directory staging. The staged verifier diff --git a/packages/squad-cli/templates/skills/gh-aw-enlistment/SKILL.md b/packages/squad-cli/templates/skills/gh-aw-enlistment/SKILL.md index c69782bac..3fafef0ff 100644 --- a/packages/squad-cli/templates/skills/gh-aw-enlistment/SKILL.md +++ b/packages/squad-cli/templates/skills/gh-aw-enlistment/SKILL.md @@ -469,19 +469,18 @@ node .github/workflows/shared/squad-install-verifier.mjs \ git diff --cached --stat unexpected_deletions="$( git diff --cached --diff-filter=D --name-only | - grep -vxF -e '.github/skills/agentic-workflows/SKILL.md' \ - -e '.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json' | + grep -vxF -e '.github/skills/agentic-workflows/SKILL.md' | while IFS= read -r path; do - # Also allowed: an ownership record whose package is a case variant, such as GitHub/Squad/workflows. + # Also allowed: any Squad ownership record except the one gh-aw writes for github/squad/workflows. if [[ "${path}" =~ ^\.github/aw/packages/[^/]+\.json$ ]] && + [ "${path}" != '.github/aw/packages/github-squad-workflows-23a7638f599e.json' ] && git show "HEAD:${path}" | node -e ' const accepted = ["github/squad/workflows", "bradygaster/squad/workflows"]; let text = ""; process.stdin.on("data", chunk => { text += chunk; }).on("end", () => { let name; try { name = JSON.parse(text).package; } catch {} - process.exit(typeof name === "string" && !accepted.includes(name) - && accepted.includes(name.toLowerCase()) ? 0 : 1); + process.exit(typeof name === "string" && accepted.includes(name.toLowerCase()) ? 0 : 1); });'; then continue fi @@ -496,12 +495,14 @@ test -z "${unexpected_deletions}" || { - The only permitted staged deletions are `.github/skills/agentic-workflows/SKILL.md`, when upgrading a repository that - previously committed gh-aw's mutable router, - `.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json`, when moving - an existing installation off the legacy `bradygaster/squad/workflows` identity, - and a `.github/aw/packages/*.json` ownership record whose `package` is a case - variant of `github/squad/workflows` or `bradygaster/squad/workflows`, such as - one left by an install typed as `GitHub/Squad/workflows`. + previously committed gh-aw's mutable router, and a `.github/aw/packages/*.json` + ownership record whose `package` is `github/squad/workflows` or + `bradygaster/squad/workflows` in any letter case, other than + `.github/aw/packages/github-squad-workflows-23a7638f599e.json`, the record gh-aw + writes for `github/squad/workflows`. Those are the records the verifier tells + you to delete, such as the superseded + `.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json` when moving + an existing installation off the legacy `bradygaster/squad/workflows` identity. - **STOP** if the staged diff shows any other **unexpected deletions**, **unexpected secrets**, edits to **unrelated files**, or committed **log/diagnostic output**. Re-scope with explicit `git add -- ` — never `git add .`, `git add -A`, or `git commit -a`. diff --git a/packages/squad-sdk/templates/skills/gh-aw-enlistment/SKILL.md b/packages/squad-sdk/templates/skills/gh-aw-enlistment/SKILL.md index c69782bac..3fafef0ff 100644 --- a/packages/squad-sdk/templates/skills/gh-aw-enlistment/SKILL.md +++ b/packages/squad-sdk/templates/skills/gh-aw-enlistment/SKILL.md @@ -469,19 +469,18 @@ node .github/workflows/shared/squad-install-verifier.mjs \ git diff --cached --stat unexpected_deletions="$( git diff --cached --diff-filter=D --name-only | - grep -vxF -e '.github/skills/agentic-workflows/SKILL.md' \ - -e '.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json' | + grep -vxF -e '.github/skills/agentic-workflows/SKILL.md' | while IFS= read -r path; do - # Also allowed: an ownership record whose package is a case variant, such as GitHub/Squad/workflows. + # Also allowed: any Squad ownership record except the one gh-aw writes for github/squad/workflows. if [[ "${path}" =~ ^\.github/aw/packages/[^/]+\.json$ ]] && + [ "${path}" != '.github/aw/packages/github-squad-workflows-23a7638f599e.json' ] && git show "HEAD:${path}" | node -e ' const accepted = ["github/squad/workflows", "bradygaster/squad/workflows"]; let text = ""; process.stdin.on("data", chunk => { text += chunk; }).on("end", () => { let name; try { name = JSON.parse(text).package; } catch {} - process.exit(typeof name === "string" && !accepted.includes(name) - && accepted.includes(name.toLowerCase()) ? 0 : 1); + process.exit(typeof name === "string" && accepted.includes(name.toLowerCase()) ? 0 : 1); });'; then continue fi @@ -496,12 +495,14 @@ test -z "${unexpected_deletions}" || { - The only permitted staged deletions are `.github/skills/agentic-workflows/SKILL.md`, when upgrading a repository that - previously committed gh-aw's mutable router, - `.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json`, when moving - an existing installation off the legacy `bradygaster/squad/workflows` identity, - and a `.github/aw/packages/*.json` ownership record whose `package` is a case - variant of `github/squad/workflows` or `bradygaster/squad/workflows`, such as - one left by an install typed as `GitHub/Squad/workflows`. + previously committed gh-aw's mutable router, and a `.github/aw/packages/*.json` + ownership record whose `package` is `github/squad/workflows` or + `bradygaster/squad/workflows` in any letter case, other than + `.github/aw/packages/github-squad-workflows-23a7638f599e.json`, the record gh-aw + writes for `github/squad/workflows`. Those are the records the verifier tells + you to delete, such as the superseded + `.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json` when moving + an existing installation off the legacy `bradygaster/squad/workflows` identity. - **STOP** if the staged diff shows any other **unexpected deletions**, **unexpected secrets**, edits to **unrelated files**, or committed **log/diagnostic output**. Re-scope with explicit `git add -- ` — never `git add .`, `git add -A`, or `git commit -a`. diff --git a/templates/skills/gh-aw-enlistment/SKILL.md b/templates/skills/gh-aw-enlistment/SKILL.md index c69782bac..3fafef0ff 100644 --- a/templates/skills/gh-aw-enlistment/SKILL.md +++ b/templates/skills/gh-aw-enlistment/SKILL.md @@ -469,19 +469,18 @@ node .github/workflows/shared/squad-install-verifier.mjs \ git diff --cached --stat unexpected_deletions="$( git diff --cached --diff-filter=D --name-only | - grep -vxF -e '.github/skills/agentic-workflows/SKILL.md' \ - -e '.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json' | + grep -vxF -e '.github/skills/agentic-workflows/SKILL.md' | while IFS= read -r path; do - # Also allowed: an ownership record whose package is a case variant, such as GitHub/Squad/workflows. + # Also allowed: any Squad ownership record except the one gh-aw writes for github/squad/workflows. if [[ "${path}" =~ ^\.github/aw/packages/[^/]+\.json$ ]] && + [ "${path}" != '.github/aw/packages/github-squad-workflows-23a7638f599e.json' ] && git show "HEAD:${path}" | node -e ' const accepted = ["github/squad/workflows", "bradygaster/squad/workflows"]; let text = ""; process.stdin.on("data", chunk => { text += chunk; }).on("end", () => { let name; try { name = JSON.parse(text).package; } catch {} - process.exit(typeof name === "string" && !accepted.includes(name) - && accepted.includes(name.toLowerCase()) ? 0 : 1); + process.exit(typeof name === "string" && accepted.includes(name.toLowerCase()) ? 0 : 1); });'; then continue fi @@ -496,12 +495,14 @@ test -z "${unexpected_deletions}" || { - The only permitted staged deletions are `.github/skills/agentic-workflows/SKILL.md`, when upgrading a repository that - previously committed gh-aw's mutable router, - `.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json`, when moving - an existing installation off the legacy `bradygaster/squad/workflows` identity, - and a `.github/aw/packages/*.json` ownership record whose `package` is a case - variant of `github/squad/workflows` or `bradygaster/squad/workflows`, such as - one left by an install typed as `GitHub/Squad/workflows`. + previously committed gh-aw's mutable router, and a `.github/aw/packages/*.json` + ownership record whose `package` is `github/squad/workflows` or + `bradygaster/squad/workflows` in any letter case, other than + `.github/aw/packages/github-squad-workflows-23a7638f599e.json`, the record gh-aw + writes for `github/squad/workflows`. Those are the records the verifier tells + you to delete, such as the superseded + `.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json` when moving + an existing installation off the legacy `bradygaster/squad/workflows` identity. - **STOP** if the staged diff shows any other **unexpected deletions**, **unexpected secrets**, edits to **unrelated files**, or committed **log/diagnostic output**. Re-scope with explicit `git add -- ` — never `git add .`, `git add -A`, or `git commit -a`. diff --git a/test/gh-aw-enlistment-skill.test.ts b/test/gh-aw-enlistment-skill.test.ts index 2e4583cba..54cd2140e 100644 --- a/test/gh-aw-enlistment-skill.test.ts +++ b/test/gh-aw-enlistment-skill.test.ts @@ -56,27 +56,26 @@ const [LEGACY_OWNERSHIP] = LEGACY_OWNERSHIP_DESTINATIONS; const SQUAD_INIT_ACTION = `${PACKAGE_NAME.replace(/\/workflows$/, '')}/.github/actions/squad-init`; const LEGACY_SQUAD_INIT_ACTION = `${LEGACY_PACKAGE_NAME.replace(/\/workflows$/, '')}/.github/actions/squad-init`; const DELETION_ALLOWLIST = [ - ` grep -vxF -e '${UNOWNED_MUTABLE_ROUTER_SKILL}' \\`, - ` -e '${LEGACY_OWNERSHIP}' |`, + ` grep -vxF -e '${UNOWNED_MUTABLE_ROUTER_SKILL}' |`, ' while IFS= read -r path; do', ].join('\n'); const DELETION_FILTER_END = ' done || true\n'; -function extractDeletionGate(content: string): string { +function extractDeletionGate(content: string, source: string): string { const start = content.indexOf('unexpected_deletions="$('); const end = content.indexOf('\n}', content.indexOf('test -z "${unexpected_deletions}"', start)); - expect(start, 'staged-deletion gate must exist').toBeGreaterThan(-1); - expect(end, 'staged-deletion gate must end with its STOP block').toBeGreaterThan(start); + expect(start, `${source}: staged-deletion gate must exist`).toBeGreaterThan(-1); + expect(end, `${source}: staged-deletion gate must end with its STOP block`).toBeGreaterThan(start); return content.slice(start, end + 2); } -/** The gate's filter: the exact allowlist, then the loop that admits only case-variant ownership records. */ -function extractDeletionFilter(content: string): string { - const gate = extractDeletionGate(content); +/** The gate's filter: the exact allowlist, then the loop that admits only stale Squad ownership records. */ +function extractDeletionFilter(content: string, source: string): string { + const gate = extractDeletionGate(content, source); const start = gate.indexOf(DELETION_ALLOWLIST); const end = gate.indexOf(DELETION_FILTER_END, start); - expect(start, 'the staged-deletion filter must start with the exact allowlist').toBeGreaterThan(-1); - expect(end, 'the staged-deletion filter must end its case-variant loop').toBeGreaterThan(start); + expect(start, `${source}: the staged-deletion filter must start with the exact allowlist`).toBeGreaterThan(-1); + expect(end, `${source}: the staged-deletion filter must end its ownership-record loop`).toBeGreaterThan(start); return gate.slice(start, end + DELETION_FILTER_END.length); } @@ -119,6 +118,8 @@ const CASE_VARIANT_PACKAGE = 'GitHub/Squad/workflows'; const CASE_VARIANT_OWNERSHIP = typedOwnershipRecord(CASE_VARIANT_PACKAGE); const LEGACY_CASE_VARIANT_PACKAGE = 'Bradygaster/Squad/workflows'; const LEGACY_CASE_VARIANT_OWNERSHIP = typedOwnershipRecord(LEGACY_CASE_VARIANT_PACKAGE); +// An exact-identity record at a path gh-aw does not write, such as a manual copy. +const NON_NATIVE_OWNERSHIP = '.github/aw/packages/squad-workflows-copy.json'; const ownershipBlob = (packageName: string) => `${JSON.stringify({ schemaVersion: 1, package: packageName })}\n`; const IDENTITY_GATE_START = '# squad-package-identity-start'; @@ -701,15 +702,19 @@ describe('no install-time fallback to the legacy package identity', () => { }); it('keeps the legacy-to-canonical move and the verifier\'s transitional acceptance of the legacy identity', () => { + const skillFilter = extractDeletionFilter(readLF(CANONICAL), CANONICAL); for (const path of [CANONICAL, GUIDE]) { const text = readLF(path); expect(fencedBlocks(text).some(block => block.includes(`${INSTALL_COMMAND} --force`) && block.includes(`\nrm -f ${LEGACY_OWNERSHIP}\n`)), `${path} must keep the upgrade's legacy-record cleanup`) .toBe(true); - expect(text, `${path} must keep the staged-deletion allowance for ${LEGACY_OWNERSHIP}`) - .toContain(`-e '${LEGACY_OWNERSHIP}'`); + expect(text, `${path} must stage deletions through the skill's ownership-record filter`).toContain(skillFilter); expect(text, `${path} must keep the migration warning allowlist`).toContain(`- ${LEGACY_SQUAD_INIT_ACTION}\n`); } + const legacyDeletion = runDeletionGate(extractDeletionGate(readLF(CANONICAL), CANONICAL), [LEGACY_OWNERSHIP], + { [LEGACY_OWNERSHIP]: ownershipBlob(LEGACY_PACKAGE_NAME) }); + expect(legacyDeletion.status, `${CANONICAL} staged-deletion gate must allow deleting ${LEGACY_OWNERSHIP} ` + + `(${LEGACY_PACKAGE_NAME}): ${legacyDeletion.stderr}`).toBe(0); expect(LEGACY_PACKAGE_NAMES, `${VERIFIER} must keep accepting existing legacy installations`) .toEqual(['bradygaster/squad/workflows']); expect(readLF(VERIFIER)).toContain('Object.freeze([PACKAGE_NAME, ...LEGACY_PACKAGE_NAMES])'); @@ -900,28 +905,33 @@ describe('gh-aw-enlistment skill', () => { expect(content).toContain('not part of the Squad package'); expect(content).toContain('Do not adopt or vendor the rest'); expect(content).toContain('.github/skills/gh-aw-enlistment/SKILL.md'); - expect(content, `${CANONICAL} must use the exact staged-deletion allowlist, then its case-variant loop`) + expect(content, `${CANONICAL} must use the exact staged-deletion allowlist, then its ownership-record loop`) .toContain(DELETION_ALLOWLIST); }); + // The legacy record is allowed by its HEAD content, not its path, so these cases supply that content. it.each([ - ['no deletions', [], 0], - ['the mutable router', [UNOWNED_MUTABLE_ROUTER_SKILL], 0], - ['the superseded legacy ownership record', [LEGACY_OWNERSHIP], 0], - ['the router and the legacy record together', [UNOWNED_MUTABLE_ROUTER_SKILL, LEGACY_OWNERSHIP], 0], - ['the canonical ownership record', [OWNERSHIP_DESTINATION], 1], - ['an installed workflow', ['.github/workflows/squad.md'], 1], - ['a legacy-record prefix match', [`${LEGACY_OWNERSHIP}.bak`], 1], - ] as const)('the staged-deletion gate handles %s', (label, deletions, status) => { - const result = runDeletionGate(extractDeletionGate(content), deletions); - expect(result.status, `${label} (${deletions.join(', ')}): ${result.stderr}`).toBe(status); + ['no deletions', [], {}, 0], + ['the mutable router', [UNOWNED_MUTABLE_ROUTER_SKILL], {}, 0], + ['the superseded legacy ownership record', [LEGACY_OWNERSHIP], + { [LEGACY_OWNERSHIP]: ownershipBlob(LEGACY_PACKAGE_NAME) }, 0], + ['the router and the legacy record together', [UNOWNED_MUTABLE_ROUTER_SKILL, LEGACY_OWNERSHIP], + { [LEGACY_OWNERSHIP]: ownershipBlob(LEGACY_PACKAGE_NAME) }, 0], + ['the legacy record without a readable HEAD blob', [LEGACY_OWNERSHIP], {}, 1], + ['the canonical ownership record', [OWNERSHIP_DESTINATION], {}, 1], + ['an installed workflow', ['.github/workflows/squad.md'], {}, 1], + ['a legacy-record prefix match', [`${LEGACY_OWNERSHIP}.bak`], {}, 1], + ] as const)('the staged-deletion gate handles %s', (label, deletions, head, status) => { + const result = runDeletionGate(extractDeletionGate(content, CANONICAL), deletions, head); + const input = `${CANONICAL} staged-deletion gate with ${label} (${deletions.join(', ')}; HEAD ${JSON.stringify(head)})`; + expect(result.status, `${input}: ${result.stderr}`).toBe(status); if (status === 1) { - expect(result.stderr).toContain(`STOP: unexpected staged deletions:\n${deletions.join('\n')}`); + expect(result.stderr, input).toContain(`STOP: unexpected staged deletions:\n${deletions.join('\n')}`); } }); - // gh-aw keys records by the identity as typed, so `gh aw add GitHub/Squad/workflows@SHA` leaves a - // record that the verifier requires to be deleted, and the gate must let exactly that deletion through. + // After a canonical reinstall the verifier says to delete every Squad ownership record except the one + // gh-aw writes for github/squad/workflows, so the gate must let exactly those deletions through. it.each([ [`the ${CASE_VARIANT_PACKAGE} record`, [CASE_VARIANT_OWNERSHIP], { [CASE_VARIANT_OWNERSHIP]: ownershipBlob(CASE_VARIANT_PACKAGE) }, []], @@ -929,11 +939,20 @@ describe('gh-aw-enlistment skill', () => { { [LEGACY_CASE_VARIANT_OWNERSHIP]: ownershipBlob(LEGACY_CASE_VARIANT_PACKAGE) }, []], ['the router, the legacy record and a case-variant record together', [UNOWNED_MUTABLE_ROUTER_SKILL, LEGACY_OWNERSHIP, CASE_VARIANT_OWNERSHIP], - { [CASE_VARIANT_OWNERSHIP]: ownershipBlob(CASE_VARIANT_PACKAGE) }, []], + { + [LEGACY_OWNERSHIP]: ownershipBlob(LEGACY_PACKAGE_NAME), + [CASE_VARIANT_OWNERSHIP]: ownershipBlob(CASE_VARIANT_PACKAGE), + }, []], + [`a non-native ${PACKAGE_NAME} duplicate`, [NON_NATIVE_OWNERSHIP], + { [NON_NATIVE_OWNERSHIP]: ownershipBlob(PACKAGE_NAME) }, []], + [`a non-native ${LEGACY_PACKAGE_NAME} duplicate`, [NON_NATIVE_OWNERSHIP], + { [NON_NATIVE_OWNERSHIP]: ownershipBlob(LEGACY_PACKAGE_NAME) }, []], [`an exact ${PACKAGE_NAME} record at a case-variant path`, [CASE_VARIANT_OWNERSHIP], - { [CASE_VARIANT_OWNERSHIP]: ownershipBlob(PACKAGE_NAME) }, [CASE_VARIANT_OWNERSHIP]], + { [CASE_VARIANT_OWNERSHIP]: ownershipBlob(PACKAGE_NAME) }, []], ['the canonical ownership record', [OWNERSHIP_DESTINATION], { [OWNERSHIP_DESTINATION]: ownershipBlob(PACKAGE_NAME) }, [OWNERSHIP_DESTINATION]], + [`the canonical ownership record path holding a ${CASE_VARIANT_PACKAGE} record`, [OWNERSHIP_DESTINATION], + { [OWNERSHIP_DESTINATION]: ownershipBlob(CASE_VARIANT_PACKAGE) }, [OWNERSHIP_DESTINATION]], ['another package\'s ownership record', ['.github/aw/packages/acme-tools-0123456789ab.json'], { '.github/aw/packages/acme-tools-0123456789ab.json': ownershipBlob('acme/tools') }, ['.github/aw/packages/acme-tools-0123456789ab.json']], @@ -952,10 +971,10 @@ describe('gh-aw-enlistment skill', () => { { [`${CASE_VARIANT_OWNERSHIP}.bak`]: ownershipBlob(CASE_VARIANT_PACKAGE) }, [`${CASE_VARIANT_OWNERSHIP}.bak`]], ['a case-variant record beside an installed workflow', [CASE_VARIANT_OWNERSHIP, '.github/workflows/squad.md'], { [CASE_VARIANT_OWNERSHIP]: ownershipBlob(CASE_VARIANT_PACKAGE) }, ['.github/workflows/squad.md']], - ] as const)('the staged-deletion gate permits only case-variant ownership records: %s', ( + ] as const)('the staged-deletion gate permits only stale Squad ownership records: %s', ( label, deletions, head, stopped, ) => { - const result = runDeletionGate(extractDeletionGate(content), deletions, head); + const result = runDeletionGate(extractDeletionGate(content, CANONICAL), deletions, head); const input = `${CANONICAL} staged-deletion gate with ${label} (${deletions.join(', ')}; HEAD ${JSON.stringify(head)})`; expect(result.status, `${input}: ${result.stderr}`).toBe(stopped.length === 0 ? 0 : 1); if (stopped.length > 0) { @@ -963,6 +982,16 @@ describe('gh-aw-enlistment skill', () => { } }); + it('lists the permitted staged deletions exactly as the gate enforces them', () => { + expect(content.replace(/\s+/g, ' '), `${CANONICAL} step 7 must describe the gate's ownership-record rule`) + .toContain( + 'and a `.github/aw/packages/*.json` ownership record whose `package` is ' + + `\`${PACKAGE_NAME}\` or \`${LEGACY_PACKAGE_NAME}\` in any letter case, other than ` + + `\`${OWNERSHIP_DESTINATION}\`, the record gh-aw writes for \`${PACKAGE_NAME}\`. Those are the records ` + + 'the verifier tells you to delete', + ); + }); + it('documents the legacy-identity migration that the verifier enforces', () => { const flat = content.replace(/\s+/g, ' '); const why = `skill must document migrating off ${LEGACY_PACKAGE_NAME} and deleting ${LEGACY_OWNERSHIP}`; @@ -1279,12 +1308,13 @@ describe('gh-aw-enlistment skill', () => { 'The only permitted staged deletions are the exact router deletion above', ); expect(flatGuide).toContain(`the superseded \`${LEGACY_OWNERSHIP}\` ownership record`); - expect(flatGuide, 'the guide must name the case-variant ownership-record deletion the verifier requires') - .toContain(`and a \`.github/aw/packages/*.json\` ownership record whose \`package\` is a case variant of ` - + `\`${PACKAGE_NAME}\` or \`${LEGACY_PACKAGE_NAME}\`, such as one left by an install typed as ` - + `\`${CASE_VARIANT_PACKAGE}\`. Any other deletion is a hard stop.`); + expect(flatGuide, `${GUIDE} must name every ownership-record deletion the verifier can require`) + .toContain('and a `.github/aw/packages/*.json` ownership record whose `package` is ' + + `\`${PACKAGE_NAME}\` or \`${LEGACY_PACKAGE_NAME}\` in any letter case, other than ` + + `\`${OWNERSHIP_DESTINATION}\`, the record gh-aw writes for \`${PACKAGE_NAME}\`. Those are the records ` + + 'the verifier tells you to delete'); expect(flatGuide).toContain('Any other deletion is a hard stop.'); - expect(guide.split(extractDeletionFilter(readLF(CANONICAL))).length - 1, + expect(guide.split(extractDeletionFilter(readLF(CANONICAL), CANONICAL)).length - 1, `both ${GUIDE} staging gates must use the ${CANONICAL} deletion filter`).toBe(2); expect(guide).toContain('.github/skills/gh-aw-enlistment/SKILL.md'); expect(agentGuide).toContain('rm -f .github/skills/agentic-workflows/SKILL.md'); diff --git a/test/gh-aw-install-staging.test.ts b/test/gh-aw-install-staging.test.ts index abb8ae192..346f23618 100644 --- a/test/gh-aw-install-staging.test.ts +++ b/test/gh-aw-install-staging.test.ts @@ -353,45 +353,51 @@ describe('gh-aw: verified ownership staging under consumer ignore rules', () => expect(git(root, 'diff', '--cached', '--diff-filter=D', '--name-only').trim()).toBe(LEGACY_OWNERSHIP); }, 120_000); - it('rejects a case-variant ownership record deleted only from the working tree until its deletion is staged', () => { + it.each([ + ['case-variant', 'GitHub/Squad/workflows', '.github/aw/packages/github-squad-workflows-9361b4232957.json', + 'Case-variant'], + ['legacy-identity duplicate', LEGACY_PACKAGE_NAME, '.github/aw/packages/squad-workflows-copy.json', 'Stale'], + ] as const)('rejects a %s ownership record deleted only from the working tree until its deletion is staged', ( + label, packageName, path, kind, + ) => { const root = consumer(); - // `gh aw add GitHub/Squad/workflows@SHA` names its record by the identity exactly as typed. - const variantName = 'GitHub/Squad/workflows'; - const variant = `.github/aw/packages/github-squad-workflows-${ - createHash('sha256').update(variantName).digest('hex').slice(0, 12)}.json`; - expect(variant).toBe('.github/aw/packages/github-squad-workflows-9361b4232957.json'); - write(root, variant, readFileSync(join(root, OWNERSHIP_DESTINATION), 'utf8') - .replaceAll(`"package": "${PACKAGE_NAME}"`, `"package": "${variantName}"`)); - git(root, 'add', '--force', '--', variant, OWNERSHIP_DESTINATION); + if (kind === 'Case-variant') { + // `gh aw add GitHub/Squad/workflows@SHA` names its record by the identity exactly as typed. + expect(path).toBe(`.github/aw/packages/github-squad-workflows-${ + createHash('sha256').update(packageName).digest('hex').slice(0, 12)}.json`); + } + write(root, path, readFileSync(join(root, OWNERSHIP_DESTINATION), 'utf8') + .replaceAll(`"package": "${PACKAGE_NAME}"`, `"package": "${packageName}"`)); + git(root, 'add', '--force', '--', path, OWNERSHIP_DESTINATION); git(root, '-c', 'user.name=Test', '-c', 'user.email=test@example.invalid', - '-c', 'core.hooksPath=/dev/null', 'commit', '--quiet', '-m', 'canonical and case-variant records'); - expect(verifyInstall(root).failures.join('\n'), `${OWNERSHIP_DESTINATION} beside committed ${variant}`) - .toContain(`case-variant record ${variant} (${variantName})`); + '-c', 'core.hooksPath=/dev/null', 'commit', '--quiet', '-m', `canonical and ${label} records`); + expect(verifyInstall(root).failures.join('\n'), `${OWNERSHIP_DESTINATION} beside committed ${path} (${packageName})`) + .toContain(`After reinstalling as ${PACKAGE_NAME}, delete ${path}.`); - rmSync(join(root, variant)); - expect(verifyInstall(root).failures, `working tree without ${variant}`).toEqual([]); + rmSync(join(root, path)); + expect(verifyInstall(root).failures, `working tree without ${path}`).toEqual([]); const args = [verifier, '--verify-staged-install', '--stage-ownership', '--source-revision', revision]; - const state = `${variant} (${variantName}) deleted only from the working tree, still in the index`; + const state = `${path} (${packageName}) deleted only from the working tree, still in the index`; const rejected = spawnSync(process.execPath, args, { cwd: root, encoding: 'utf8' }); expect(rejected.status, `--verify-staged-install with ${state}:\n${rejected.stderr}`).toBe(1); expect(rejected.stderr, `--verify-staged-install with ${state}`).toContain( - `Case-variant Squad package ownership record remains in staged tree: ${variant} (${variantName}); ` + `${kind} Squad package ownership record remains in staged tree: ${path} (${packageName}); ` + 'delete it and stage the deletion before commit/push.', ); git(root, 'add', '--', '.github/aw/'); - expect(git(root, 'diff', '--cached', '--diff-filter=D', '--name-only').trim()).toBe(variant); + expect(git(root, 'diff', '--cached', '--diff-filter=D', '--name-only').trim()).toBe(path); const accepted = spawnSync(process.execPath, args, { cwd: root, encoding: 'utf8' }); - expect(accepted.status, `--verify-staged-install after staging the deletion of ${variant}:\n${accepted.stderr}`) + expect(accepted.status, `--verify-staged-install after staging the deletion of ${path}:\n${accepted.stderr}`) .toBe(0); // The committed result is what a clean checkout verifies. git(root, '-c', 'user.name=Test', '-c', 'user.email=test@example.invalid', - '-c', 'core.hooksPath=/dev/null', 'commit', '--quiet', '-m', 'delete the case-variant record'); + '-c', 'core.hooksPath=/dev/null', 'commit', '--quiet', '-m', `delete the ${label} record`); const clone = mkdtempSync(join(tmpdir(), 'squad-install-staging-clone-')); roots.push(clone); execFileSync('git', ['-c', 'core.autocrlf=false', 'clone', '--quiet', root, clone]); - expect(existsSync(join(clone, variant)), `clean checkout must not contain ${variant}`).toBe(false); + expect(existsSync(join(clone, path)), `clean checkout must not contain ${path}`).toBe(false); expect(verifyInstall(clone, { expectedRevision: revision }).failures, `clean checkout of ${root}`).toEqual([]); }, 120_000); diff --git a/test/gh-aw-quality.test.ts b/test/gh-aw-quality.test.ts index 3d1cd937e..27b275ab8 100644 --- a/test/gh-aw-quality.test.ts +++ b/test/gh-aw-quality.test.ts @@ -4796,27 +4796,97 @@ describe('gh-aw: canonical package integrity contract', () => { ).toContain(`Installed source binding is invalid for ${sourcePath}.`); }); + /** Moves the fixture's ownership record to the path gh-aw writes for `packageName`, and returns that path. */ + function moveRecordToNativePath(root: string, packageName: string): string { + const destination = packageOwnershipDestination(packageName); + writeFileSync(join(root, destination), readText(join(root, OWNERSHIP_FIXTURE))); + unlinkSync(join(root, OWNERSHIP_FIXTURE)); + return destination; + } + + /** Copies the ownership record at `from` to `to`, declaring `packageName`. */ + function copyRecord(root: string, from: string, to: string, packageName: string): void { + const record = JSON.parse(readText(join(root, from))); + writeFileSync(join(root, to), `${JSON.stringify({ + ...record, package: packageName, source: `${packageName}@${record.resolvedCommit}`, + }, null, 2)}\n`); + } + + /** Deletes exactly the records the verifier's cleanup advice names, as a user following it would. */ + function followCleanupAdvice(root: string, failure: string, state: string): string[] { + const advice = new RegExp(`After reinstalling as ${PACKAGE_NAME}, delete (.+)\\.$`, 'm').exec(failure); + expect(advice, `${state}: cleanup advice in ${failure}`).not.toBeNull(); + const paths = advice![1].split(', '); + for (const path of paths) unlinkSync(join(root, path)); + return paths; + } + it('fails closed with migration guidance while both identity records exist', () => { // `gh aw add github/squad/workflows@ --force` over a legacy install leaves this state. const root = makeConsumer(); - const record = JSON.parse(readText(join(root, OWNERSHIP_FIXTURE))); + moveRecordToNativePath(root, PACKAGE_NAME); const [legacyPath] = LEGACY_OWNERSHIP_DESTINATIONS; - mkdirSync(dirname(join(root, legacyPath)), { recursive: true }); - writeFileSync(join(root, legacyPath), `${JSON.stringify({ - ...record, - package: LEGACY_PACKAGE_NAME, - source: `${LEGACY_PACKAGE_NAME}@${revisionA}`, - }, null, 2)}\n`); + copyRecord(root, OWNERSHIP_DESTINATION, legacyPath, LEGACY_PACKAGE_NAME); const failure = verifyInstall(root).failures.join('\n'); - const state = `${OWNERSHIP_FIXTURE} (${PACKAGE_NAME}) and ${legacyPath} (${LEGACY_PACKAGE_NAME}) both present`; - expect(failure, state).toContain('Expected exactly one Squad package ownership record; found 2'); - expect(failure, state).toContain(`${legacyPath} (${LEGACY_PACKAGE_NAME})`); - expect(failure, `${state}: the guidance must move to ${PACKAGE_NAME}`) - .toContain(`. After reinstalling as ${PACKAGE_NAME}, delete ${legacyPath}.`); + const state = `${OWNERSHIP_DESTINATION} (${PACKAGE_NAME}) and ${legacyPath} (${LEGACY_PACKAGE_NAME}) both present`; + expect(failure, `${state} must list both records and delete only the legacy one`).toContain( + `Expected exactly one Squad package ownership record; found 2: ${legacyPath} (${LEGACY_PACKAGE_NAME}), ` + + `${OWNERSHIP_DESTINATION} (${PACKAGE_NAME}), so more than one record claims the installed files. ` + + `After reinstalling as ${PACKAGE_NAME}, delete ${legacyPath}.`, + ); expect(failure, `${state}: the guidance must not offer keeping the ${LEGACY_PACKAGE_NAME} record`) .not.toContain('Keep only the record whose identity matches the installed workflow source bindings'); - unlinkSync(join(root, legacyPath)); - expect(verifyInstall(root, { expectedRevision: revisionA }).failures).toEqual([]); + expect(followCleanupAdvice(root, failure, state)).toEqual([legacyPath]); + expect(verifyInstall(root, { expectedRevision: revisionA }).failures, `${state} after following the advice`) + .toEqual([]); + }); + + it('lists every record and keeps only the native canonical one when canonical, legacy and case-variant ' + + 'records coexist', () => { + const variant = 'GitHub/Squad/workflows'; + const variantPath = '.github/aw/packages/github-squad-workflows-' + + `${createHash('sha256').update(variant).digest('hex').slice(0, 12)}.json`; + const [legacyPath] = LEGACY_OWNERSHIP_DESTINATIONS; + const root = makeConsumer(); + moveRecordToNativePath(root, PACKAGE_NAME); + copyRecord(root, OWNERSHIP_DESTINATION, legacyPath, LEGACY_PACKAGE_NAME); + copyRecord(root, OWNERSHIP_DESTINATION, variantPath, variant); + const state = `${OWNERSHIP_DESTINATION} (${PACKAGE_NAME}), ${legacyPath} (${LEGACY_PACKAGE_NAME}) ` + + `and ${variantPath} (${variant})`; + const failure = verifyInstall(root, { expectedRevision: revisionA }).failures.join('\n'); + expect(failure, `${state} must list all three records`).toContain( + `Expected exactly one Squad package ownership record; found 3: ${legacyPath} (${LEGACY_PACKAGE_NAME}), ` + + `${OWNERSHIP_DESTINATION} (${PACKAGE_NAME}) and case-variant record ${variantPath} (${variant}), ` + + 'so more than one record claims the installed files.', + ); + expect(failure, `${state} must delete the legacy and case-variant records, never ${OWNERSHIP_DESTINATION}`) + .toContain(`After reinstalling as ${PACKAGE_NAME}, delete ${legacyPath}, ${variantPath}.`); + expect(followCleanupAdvice(root, failure, state)).toEqual([legacyPath, variantPath]); + expect(verifyInstall(root, { expectedRevision: revisionA }).failures, `${state} after following the advice`) + .toEqual([]); + }); + + it.each([ + ['canonical', PACKAGE_NAME], + ['legacy', LEGACY_PACKAGE_NAME], + ] as const)('names the actual path of a non-native %s ownership record duplicated beside the canonical record', ( + label, duplicateName, + ) => { + const duplicatePath = '.github/aw/packages/squad-workflows-copy.json'; + const root = makeConsumer(); + moveRecordToNativePath(root, PACKAGE_NAME); + copyRecord(root, OWNERSHIP_DESTINATION, duplicatePath, duplicateName); + const state = `${OWNERSHIP_DESTINATION} (${PACKAGE_NAME}) beside ${label} duplicate ${duplicatePath} ` + + `(${duplicateName})`; + const failure = verifyInstall(root, { expectedRevision: revisionA }).failures.join('\n'); + expect(failure, `${state} must name the duplicate's own path`).toContain( + `Expected exactly one Squad package ownership record; found 2: ${OWNERSHIP_DESTINATION} (${PACKAGE_NAME}), ` + + `${duplicatePath} (${duplicateName}), so more than one record claims the installed files. ` + + `After reinstalling as ${PACKAGE_NAME}, delete ${duplicatePath}.`, + ); + expect(followCleanupAdvice(root, failure, state)).toEqual([duplicatePath]); + expect(verifyInstall(root, { expectedRevision: revisionA }).failures, `${state} after following the advice`) + .toEqual([]); }); it('rejects unknown identities and cross-identity ownership sources', () => { @@ -4853,7 +4923,8 @@ describe('gh-aw: canonical package integrity contract', () => { expect(failure, state).toContain( `Found ${OWNERSHIP_FIXTURE} (${variant}): gh-aw records the package identity exactly as typed`, ); - expect(failure, state).toContain(`Reinstall as ${PACKAGE_NAME}, then delete that record.`); + expect(failure, `${state} must name the record to delete`) + .toContain(`After reinstalling as ${PACKAGE_NAME}, delete ${OWNERSHIP_FIXTURE}.`); writeFileSync(ownershipPath, JSON.stringify({ ...record, package: 'attacker/squad/workflows', source: `attacker/squad/workflows@${revisionA}`, @@ -4862,6 +4933,21 @@ describe('gh-aw: canonical package integrity contract', () => { .not.toContain('exactly as typed'); }); + it('asks only for a reinstall when the lone case-variant record sits at the native canonical path', () => { + // The canonical reinstall overwrites that record, so nothing is left to delete. + const variant = 'GitHub/Squad/workflows'; + const root = makeConsumer(); + moveRecordToNativePath(root, PACKAGE_NAME); + copyRecord(root, OWNERSHIP_DESTINATION, OWNERSHIP_DESTINATION, variant); + const failure = verifyInstall(root).failures.join('\n'); + const state = `${OWNERSHIP_DESTINATION} declares ${variant}`; + expect(failure, state).toContain( + `Found ${OWNERSHIP_DESTINATION} (${variant}): gh-aw records the package identity exactly as typed and Squad ` + + `matches it case-sensitively. Reinstall as ${PACKAGE_NAME}.`, + ); + expect(failure, `${state} must not ask to delete ${OWNERSHIP_DESTINATION}`).not.toMatch(/\bdelete\b/); + }); + it.each([ ['canonical', PACKAGE_NAME, 'workflow'], ['legacy', LEGACY_PACKAGE_NAME, 'package'], @@ -4872,23 +4958,21 @@ describe('gh-aw: canonical package integrity contract', () => { const variantPath = '.github/aw/packages/github-squad-workflows-' + `${createHash('sha256').update(variant).digest('hex').slice(0, 12)}.json`; const root = makeConsumer(revisionA, true, binding, exactName); - const record = JSON.parse(readText(join(root, OWNERSHIP_FIXTURE))); - writeFileSync(join(root, variantPath), `${JSON.stringify({ - ...record, package: variant, source: `${variant}@${revisionA}`, - }, null, 2)}\n`); - // Squad always moves to PACKAGE_NAME, so an exact legacy record is superseded too. - const stale = exactName === PACKAGE_NAME ? [variantPath] : [OWNERSHIP_FIXTURE, variantPath]; - const state = `${label} ${OWNERSHIP_FIXTURE} (${exactName}) beside ${variantPath} (${variant})`; + const exactPath = moveRecordToNativePath(root, exactName); + copyRecord(root, exactPath, variantPath, variant); + // After the canonical reinstall only OWNERSHIP_DESTINATION stays, so an exact legacy record goes too. + const stale = [exactPath, variantPath].filter(path => path !== OWNERSHIP_DESTINATION); + const state = `${label} ${exactPath} (${exactName}) beside ${variantPath} (${variant})`; const failure = verifyInstall(root, { expectedRevision: revisionA }).failures.join('\n'); - expect(failure, `${state} must fail verification`).toContain( - `Expected exactly one Squad package ownership record; found ${OWNERSHIP_FIXTURE} (${exactName}) ` + expect(failure, `${state} must list both records`).toContain( + `Expected exactly one Squad package ownership record; found 2: ${exactPath} (${exactName}) ` + `and case-variant record ${variantPath} (${variant}), so more than one record claims the installed files.`, ); expect(failure, `${state} must recommend ${PACKAGE_NAME} and name every record to delete`) .toContain(`After reinstalling as ${PACKAGE_NAME}, delete ${stale.join(', ')}.`); if (exactName !== PACKAGE_NAME) return; - unlinkSync(join(root, variantPath)); - expect(verifyInstall(root, { expectedRevision: revisionA }).failures, `${state} after deleting ${variantPath}`) + expect(followCleanupAdvice(root, failure, state)).toEqual([variantPath]); + expect(verifyInstall(root, { expectedRevision: revisionA }).failures, `${state} after following the advice`) .toEqual([]); }); diff --git a/workflows/package/squad-bootstrap.md b/workflows/package/squad-bootstrap.md index 085847095..9d8c274f7 100644 --- a/workflows/package/squad-bootstrap.md +++ b/workflows/package/squad-bootstrap.md @@ -153,7 +153,7 @@ pre-agent-steps: node --check "$path" >/dev/null } # BEGIN GENERATED RESOURCE DIGESTS - check_hash "$install_verifier" "df0cf0796cf5d2e30a182f425279d6e849f2147f24077def7fda61655cd8358d" + check_hash "$install_verifier" "514683e50c87644a1bf3977727f84e7bece7a94afc1f5ff5b5cf4c5e3ec2b77e" check_hash "$cast_validator" "15f0d47917d269b5b967dc2a782a0eb761c39af7a92ddb64fed687a427070760" check_hash "$bootstrap_validator" "d552d041f006249bfaa67b10a75877f813796b7698c718ac7eb7cf16f3a72e31" # END GENERATED RESOURCE DIGESTS diff --git a/workflows/shared/squad-install-verifier.mjs b/workflows/shared/squad-install-verifier.mjs index 9a5eac0f9..287d0ec68 100644 --- a/workflows/shared/squad-install-verifier.mjs +++ b/workflows/shared/squad-install-verifier.mjs @@ -139,6 +139,11 @@ function isCaseVariantPackageName(packageName) { && ACCEPTED_PACKAGE_NAMES.some(accepted => accepted.toLowerCase() === packageName.toLowerCase()); } +// Any record findOwnershipRecord counts: an accepted identity in any letter case. +function isSquadPackageName(packageName) { + return ACCEPTED_PACKAGE_NAMES.includes(packageName) || isCaseVariantPackageName(packageName); +} + // Mirrors gh-aw v0.91.5 stablePackageID: lowercase slug plus the first 12 hex digits of // SHA-256 over the package identity exactly as installed. export function packageOwnershipDestination(packageName) { @@ -771,7 +776,8 @@ function findOwnershipRecord(root) { if (!existsSync(packageDir)) throw new Error('Package ownership metadata is missing.'); const matches = []; const caseVariants = []; - for (const name of readdirSync(packageDir)) { + // Sorted, so the records and the cleanup advice below read the same on every platform. + for (const name of readdirSync(packageDir).sort()) { if (!name.endsWith('.json')) continue; const relativePath = `.github/aw/packages/${name}`; let record; @@ -787,31 +793,28 @@ function findOwnershipRecord(root) { } } const describeRecords = records => records.map(({ path, record }) => `${path} (${record.package})`).join(', '); - if (matches.length > 1) { - throw new Error( - `Expected exactly one Squad package ownership record; found ${matches.length}: ` - + `${describeRecords(matches)}. ` - + `After reinstalling as ${PACKAGE_NAME}, delete ${LEGACY_OWNERSHIP_DESTINATIONS.join(', ')}.`, - ); - } - if (matches.length === 1 && caseVariants.length > 0) { - // gh-aw keys records by the identity exactly as typed, so a case-only reinstall keeps the old record. - // Always move to PACKAGE_NAME; a legacy exact record is then superseded as well. - const [match] = matches; - const stale = [...(match.record.package === PACKAGE_NAME ? [] : [match]), ...caseVariants]; + // A successful `gh aw add PACKAGE_NAME@ --force` writes OWNERSHIP_DESTINATION, so every other + // Squad record is stale: superseded, duplicated at another path, or a case variant. + const cleanup = records => { + const stale = records.map(({ path }) => path).filter(path => path !== OWNERSHIP_DESTINATION); + return stale.length === 0 + ? `Reinstall as ${PACKAGE_NAME}.` + : `After reinstalling as ${PACKAGE_NAME}, delete ${stale.join(', ')}.`; + }; + const caseHint = 'gh-aw records the package identity exactly as typed and Squad matches it case-sensitively.'; + if (matches.length > 0 && matches.length + caseVariants.length > 1) { + const variants = caseVariants.length === 0 ? '' + : ` and case-variant ${caseVariants.length === 1 ? 'record' : 'records'} ${describeRecords(caseVariants)}`; throw new Error( - `Expected exactly one Squad package ownership record; found ${describeRecords(matches)} and case-variant ` - + `${caseVariants.length === 1 ? 'record' : 'records'} ${describeRecords(caseVariants)}, ` - + 'so more than one record claims the installed files. gh-aw records the package identity exactly as typed ' - + `and Squad matches it case-sensitively. After reinstalling as ${PACKAGE_NAME}, delete ` - + `${stale.map(({ path }) => path).join(', ')}.`, + `Expected exactly one Squad package ownership record; found ${matches.length + caseVariants.length}: ` + + `${describeRecords(matches)}${variants}, so more than one record claims the installed files. ` + + `${caseVariants.length === 0 ? '' : `${caseHint} `}${cleanup([...matches, ...caseVariants])}`, ); } - if (matches.length !== 1) { + if (matches.length === 0) { // Identity matching stays exact; this only explains a case-only mismatch. - const hint = caseVariants.length === 0 ? '' : ` Found ${describeRecords(caseVariants)}: gh-aw records the ` - + 'package identity exactly as typed and Squad matches it case-sensitively. ' - + `Reinstall as ${PACKAGE_NAME}, then delete that record.`; + const hint = caseVariants.length === 0 ? '' + : ` Found ${describeRecords(caseVariants)}: ${caseHint} ${cleanup(caseVariants)}`; throw new Error( `Expected exactly one ${ACCEPTED_PACKAGE_NAMES.join(' or ')} ownership record; found 0.${hint}`, ); @@ -1168,7 +1171,7 @@ export function verifyStagedInstall(root, { expectedRevision = '', stageOwnershi + 'delete it and stage the deletion before commit/push.'); } // A record deleted only from the working tree still reaches a clean checkout, where - // findOwnershipRecord rejects a case variant beside the verified record. + // findOwnershipRecord rejects any other Squad record beside the verified one. for (const [path, [, type, object]] of staged) { if (type !== 'blob' || required.has(path) || !/^\.github\/aw\/packages\/[^/]+\.json$/.test(path)) continue; const bytes = spawnChecked('git', ['cat-file', 'blob', object], root).stdout; @@ -1178,10 +1181,10 @@ export function verifyStagedInstall(root, { expectedRevision = '', stageOwnershi } catch { continue; } - if (isCaseVariantPackageName(packageName)) { + if (isSquadPackageName(packageName)) { throw new Error( - `Case-variant Squad package ownership record remains in staged tree: ${path} (${packageName}); ` - + 'delete it and stage the deletion before commit/push.', + `${isCaseVariantPackageName(packageName) ? 'Case-variant' : 'Stale'} Squad package ownership record ` + + `remains in staged tree: ${path} (${packageName}); delete it and stage the deletion before commit/push.`, ); } } diff --git a/workflows/skills/gh-aw-enlistment/SKILL.md b/workflows/skills/gh-aw-enlistment/SKILL.md index c69782bac..3fafef0ff 100644 --- a/workflows/skills/gh-aw-enlistment/SKILL.md +++ b/workflows/skills/gh-aw-enlistment/SKILL.md @@ -469,19 +469,18 @@ node .github/workflows/shared/squad-install-verifier.mjs \ git diff --cached --stat unexpected_deletions="$( git diff --cached --diff-filter=D --name-only | - grep -vxF -e '.github/skills/agentic-workflows/SKILL.md' \ - -e '.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json' | + grep -vxF -e '.github/skills/agentic-workflows/SKILL.md' | while IFS= read -r path; do - # Also allowed: an ownership record whose package is a case variant, such as GitHub/Squad/workflows. + # Also allowed: any Squad ownership record except the one gh-aw writes for github/squad/workflows. if [[ "${path}" =~ ^\.github/aw/packages/[^/]+\.json$ ]] && + [ "${path}" != '.github/aw/packages/github-squad-workflows-23a7638f599e.json' ] && git show "HEAD:${path}" | node -e ' const accepted = ["github/squad/workflows", "bradygaster/squad/workflows"]; let text = ""; process.stdin.on("data", chunk => { text += chunk; }).on("end", () => { let name; try { name = JSON.parse(text).package; } catch {} - process.exit(typeof name === "string" && !accepted.includes(name) - && accepted.includes(name.toLowerCase()) ? 0 : 1); + process.exit(typeof name === "string" && accepted.includes(name.toLowerCase()) ? 0 : 1); });'; then continue fi @@ -496,12 +495,14 @@ test -z "${unexpected_deletions}" || { - The only permitted staged deletions are `.github/skills/agentic-workflows/SKILL.md`, when upgrading a repository that - previously committed gh-aw's mutable router, - `.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json`, when moving - an existing installation off the legacy `bradygaster/squad/workflows` identity, - and a `.github/aw/packages/*.json` ownership record whose `package` is a case - variant of `github/squad/workflows` or `bradygaster/squad/workflows`, such as - one left by an install typed as `GitHub/Squad/workflows`. + previously committed gh-aw's mutable router, and a `.github/aw/packages/*.json` + ownership record whose `package` is `github/squad/workflows` or + `bradygaster/squad/workflows` in any letter case, other than + `.github/aw/packages/github-squad-workflows-23a7638f599e.json`, the record gh-aw + writes for `github/squad/workflows`. Those are the records the verifier tells + you to delete, such as the superseded + `.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json` when moving + an existing installation off the legacy `bradygaster/squad/workflows` identity. - **STOP** if the staged diff shows any other **unexpected deletions**, **unexpected secrets**, edits to **unrelated files**, or committed **log/diagnostic output**. Re-scope with explicit `git add -- ` — never `git add .`, `git add -A`, or `git commit -a`. diff --git a/workflows/squad-bootstrap.md b/workflows/squad-bootstrap.md index b2e8fc410..708fe938f 100644 --- a/workflows/squad-bootstrap.md +++ b/workflows/squad-bootstrap.md @@ -160,7 +160,7 @@ pre-agent-steps: node --check "$path" >/dev/null } # BEGIN GENERATED RESOURCE DIGESTS - check_hash "$install_verifier" "df0cf0796cf5d2e30a182f425279d6e849f2147f24077def7fda61655cd8358d" + check_hash "$install_verifier" "514683e50c87644a1bf3977727f84e7bece7a94afc1f5ff5b5cf4c5e3ec2b77e" check_hash "$cast_validator" "15f0d47917d269b5b967dc2a782a0eb761c39af7a92ddb64fed687a427070760" check_hash "$bootstrap_validator" "d552d041f006249bfaa67b10a75877f813796b7698c718ac7eb7cf16f3a72e31" # END GENERATED RESOURCE DIGESTS diff --git a/workflows/squad-workflows.manifest.json b/workflows/squad-workflows.manifest.json index a4d5e079b..95baf1d7a 100644 --- a/workflows/squad-workflows.manifest.json +++ b/workflows/squad-workflows.manifest.json @@ -69,9 +69,9 @@ "source": "workflows/package/squad-bootstrap.md", "destination": ".github/workflows/squad-bootstrap.md", "lock": ".github/workflows/squad-bootstrap.lock.yml", - "source_sha256": "1757013672276715d00b5597bf1ba9977ac2c45123f20f129ebaa5337bf1a82a", - "lock_sha256": "ff537d2faf67c85922e039f7a6770aadaaab6244d6d0c57c4941dd82f1c67758", - "package_lock_sha256": "3e5749f375785c9e0e31960df601a3e0a216d0cfcd6880c5474af4e144f66346" + "source_sha256": "fe92a3c7a4402f3f57cf1e041fb78f11ed62312c919271a931773612eceb87bf", + "lock_sha256": "7a9cb513812da3c43e03d14ba06ecff0375d41b3720e64e0d7937001082fd3d0", + "package_lock_sha256": "a3b20ed0fb1823b56ba82144b39eb020448bb8674ad5aa957781a41be54a00b8" }, { "name": "squad-command-router", @@ -119,8 +119,8 @@ }, { "name": "squad-bootstrap", - "lock_sha256": "abba2f4eb7406942929f9d365f1c4b3d99c1764fc5ca2b2719b5428893833c7f", - "package_lock_sha256": "7b25f86c8e5aad5eceb34773eec25804ad104e0a65eb57bc1fed40a7243f27ef" + "lock_sha256": "e5fbfe1af46967e453d37aa1222d97704f00cc5e52e39cd2070f7cc7538d4222", + "package_lock_sha256": "507b720abf3ec96106a2f1ab21b2fde6cd1088814f06ea6054f3c58ee7291ef4" }, { "name": "squad-command-router", @@ -169,7 +169,7 @@ "package_destination": ".github/workflows/shared/squad-install-verifier.mjs", "destination": ".github/workflows/shared/squad-install-verifier.mjs", "ownership": "manifest", - "sha256": "df0cf0796cf5d2e30a182f425279d6e849f2147f24077def7fda61655cd8358d" + "sha256": "514683e50c87644a1bf3977727f84e7bece7a94afc1f5ff5b5cf4c5e3ec2b77e" }, { "path": "shared/squad-command-contract.mjs", @@ -305,7 +305,7 @@ "name": "gh-aw-enlistment", "source": "workflows/skills/gh-aw-enlistment/SKILL.md", "destination": ".github/skills/gh-aw-enlistment/SKILL.md", - "sha256": "7efe18f78bd8c2bd9ea060fd049ca6de2389f961cac5d3295f8ad83dc77596bd" + "sha256": "a0c3eb2202cf2afdd66e334769c4ce88407d10b7714f86fc869da8efe8c76881" } ], "bootstrap": {