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..97b29ddd4 --- /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 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/.github/actions/squad-init/action.yml b/.github/actions/squad-init/action.yml index 8c73caa75..e2cfb5b8f 100644 --- a/.github/actions/squad-init/action.yml +++ b/.github/actions/squad-init/action.yml @@ -9,11 +9,12 @@ # 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 # 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. @@ -41,7 +42,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..e2af3fdc6 100644 --- a/.github/agents.md +++ b/.github/agents.md @@ -49,10 +49,19 @@ 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. 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. 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..3fafef0ff 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 "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 +} +# 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. 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, and installs exactly eight workflows, seventeen runtime resources, and one @@ -226,6 +250,65 @@ 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. 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 +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 the +upgrade is committed, every strict compile repeats that change as four +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.}" +[[ "${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 "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 +} +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 +gh aw compile --strict +node .github/workflows/shared/squad-install-verifier.mjs \ + --verify-install \ + --source-revision "${SQUAD_SHA}" \ + --strict-compile +``` + +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`; five action issues and three dispatches per wake-up remain separate caps. @@ -247,7 +330,7 @@ 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`** > **These are referenced names, not prerequisites.** `gh aw add` lists the secrets @@ -307,11 +390,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 @@ -349,7 +469,23 @@ 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' | + while IFS= read -r path; do + # 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.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 @@ -357,9 +493,16 @@ 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 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`. @@ -498,7 +641,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 "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 +} +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,13 +722,14 @@ 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`. Anything else is a STOP. - ❌ **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/README.md b/README.md index 2f6999313..e40acc9a3 100644 --- a/README.md +++ b/README.md @@ -600,7 +600,24 @@ 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_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="" +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 "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 +} +gh aw add "github/squad/workflows@${SQUAD_SHA}" rm -f .github/skills/agentic-workflows/SKILL.md git add -- \ .github/aw/ \ @@ -611,6 +628,14 @@ 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. 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/features/standalone-install.md b/docs/src/content/docs/features/standalone-install.md index ece2d1cda..eb5c6b9b1 100644 --- a/docs/src/content/docs/features/standalone-install.md +++ b/docs/src/content/docs/features/standalone-install.md @@ -131,11 +131,12 @@ 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 + 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/docs/src/content/docs/guide/gh-aw.md b/docs/src/content/docs/guide/gh-aw.md index 0ce64aa73..48f8f5721 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 "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 +} +# 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,23 @@ 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' | + while IFS= read -r path; do + # 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.toLowerCase()) ? 0 : 1); + });'; then + continue + fi + printf '%s\n' "${path}" + done || true )" test -z "${unexpected_deletions}" git commit -m "ci: add Squad agentic workflow" @@ -550,10 +583,32 @@ 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 "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 +} +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. 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 auto-discovered. It installs exactly eight agentic workflows, twenty-one runtime @@ -641,7 +696,7 @@ 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` > **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 @@ -694,11 +749,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 @@ -721,7 +813,23 @@ 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' | + while IFS= read -r path; do + # 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.toLowerCase()) ? 0 : 1); + });'; then + continue + fi + printf '%s\n' "${path}" + done || true )" test -z "${unexpected_deletions}" git commit -m "ci: add Squad agentic workflow" @@ -745,9 +853,16 @@ 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 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). 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 +2621,30 @@ 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="" +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 "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 +} -gh aw add "bradygaster/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 gh aw compile --strict node .github/workflows/shared/squad-install-verifier.mjs \ @@ -2519,20 +2655,60 @@ 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. +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. 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 +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. +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. 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 `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..3fafef0ff 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 "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 +} +# 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. 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, and installs exactly eight workflows, seventeen runtime resources, and one @@ -226,6 +250,65 @@ 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. 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 +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 the +upgrade is committed, every strict compile repeats that change as four +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.}" +[[ "${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 "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 +} +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 +gh aw compile --strict +node .github/workflows/shared/squad-install-verifier.mjs \ + --verify-install \ + --source-revision "${SQUAD_SHA}" \ + --strict-compile +``` + +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`; five action issues and three dispatches per wake-up remain separate caps. @@ -247,7 +330,7 @@ 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`** > **These are referenced names, not prerequisites.** `gh aw add` lists the secrets @@ -307,11 +390,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 @@ -349,7 +469,23 @@ 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' | + while IFS= read -r path; do + # 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.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 @@ -357,9 +493,16 @@ 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 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`. @@ -498,7 +641,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 "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 +} +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,13 +722,14 @@ 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`. Anything else is a STOP. - ❌ **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 0a02b6d01..3fafef0ff 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 "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 +} +# 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. 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, and installs exactly eight workflows, seventeen runtime resources, and one @@ -226,6 +250,65 @@ 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. 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 +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 the +upgrade is committed, every strict compile repeats that change as four +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.}" +[[ "${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 "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 +} +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 +gh aw compile --strict +node .github/workflows/shared/squad-install-verifier.mjs \ + --verify-install \ + --source-revision "${SQUAD_SHA}" \ + --strict-compile +``` + +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`; five action issues and three dispatches per wake-up remain separate caps. @@ -247,7 +330,7 @@ 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`** > **These are referenced names, not prerequisites.** `gh aw add` lists the secrets @@ -307,11 +390,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 @@ -349,7 +469,23 @@ 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' | + while IFS= read -r path; do + # 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.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 @@ -357,9 +493,16 @@ 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 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`. @@ -498,7 +641,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 "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 +} +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,13 +722,14 @@ 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`. Anything else is a STOP. - ❌ **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/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..3fafef0ff 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 "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 +} +# 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. 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, and installs exactly eight workflows, seventeen runtime resources, and one @@ -226,6 +250,65 @@ 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. 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 +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 the +upgrade is committed, every strict compile repeats that change as four +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.}" +[[ "${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 "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 +} +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 +gh aw compile --strict +node .github/workflows/shared/squad-install-verifier.mjs \ + --verify-install \ + --source-revision "${SQUAD_SHA}" \ + --strict-compile +``` + +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`; five action issues and three dispatches per wake-up remain separate caps. @@ -247,7 +330,7 @@ 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`** > **These are referenced names, not prerequisites.** `gh aw add` lists the secrets @@ -307,11 +390,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 @@ -349,7 +469,23 @@ 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' | + while IFS= read -r path; do + # 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.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 @@ -357,9 +493,16 @@ 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 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`. @@ -498,7 +641,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 "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 +} +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,13 +722,14 @@ 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`. Anything else is a STOP. - ❌ **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/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..54cd2140e 100644 --- a/test/gh-aw-enlistment-skill.test.ts +++ b/test/gh-aw-enlistment-skill.test.ts @@ -11,14 +11,28 @@ * 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 { 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'; 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, + WORKFLOW_TUPLES, + compileWithPinnedActions, +} from '../workflows/shared/squad-install-verifier.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(__dirname, '..'); @@ -35,6 +49,128 @@ 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 LEGACY_SQUAD_INIT_ACTION = `${LEGACY_PACKAGE_NAME.replace(/\/workflows$/, '')}/.github/actions/squad-init`; +const DELETION_ALLOWLIST = [ + ` 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, source: string): string { + const start = content.indexOf('unexpected_deletions="$('); + const end = content.indexOf('\n}', content.indexOf('test -z "${unexpected_deletions}"', 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 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, `${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); +} + +/** 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() { + 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(''), + 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); +// 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'; +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_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, ' + + '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'); } @@ -113,13 +249,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); @@ -156,6 +442,343 @@ 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'); + }); +}); + +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'); + }, + ); +}); + +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', () => { + 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 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])'); + }); +}); + +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', () => { @@ -236,7 +859,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 +872,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,16 +900,247 @@ 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, `${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], + { [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, input).toContain(`STOP: unexpected staged deletions:\n${deletions.join('\n')}`); + } + }); + + // 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) }, []], + [`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], + { + [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) }, []], + ['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']], + ['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 stale Squad ownership records: %s', ( + label, deletions, head, stopped, + ) => { + 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) { + expect(result.stderr, input).toContain(`STOP: unexpected staged deletions:\n${stopped.join('\n')}\n`); + } + }); + + 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}`; + 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 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.'); + 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 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 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', () => { + 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 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.`, ); }); + 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 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 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.', + ); + }); + + 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', () => { // The standalone command must appear as its own line (start-of-line in a // fenced bash block), not merely as a prose/backtick mention. The @@ -318,7 +1176,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 +1287,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 +1300,80 @@ 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, `${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), 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'); }); + 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, ' '), `${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`); + }); + + 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 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\\.`), + ); + } + }); + 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 +1402,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 +1413,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 +1451,48 @@ 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 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.'); + 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-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..346f23618 100644 --- a/test/gh-aw-install-staging.test.ts +++ b/test/gh-aw-install-staging.test.ts @@ -1,17 +1,22 @@ 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 { 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'; 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 +28,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 +42,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'); @@ -69,6 +76,41 @@ 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, 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() { + 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 + 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), MATERIALIZE: materialize ? '1' : '0' }, + }); + 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 }); @@ -141,10 +183,13 @@ 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}, ` + + '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'); expect(blobReads).toHaveLength(new Set(required).size); @@ -225,11 +270,12 @@ 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`, - ` rm -f ${UNOWNED_MUTABLE_ROUTER_SKILL}`, - ' gh aw compile --strict', + 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} &&`, + ' 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')); @@ -270,6 +316,190 @@ 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.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(); + 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 ${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, path)); + expect(verifyInstall(root).failures, `working tree without ${path}`).toEqual([]); + const args = [verifier, '--verify-staged-install', '--stage-ownership', '--source-revision', revision]; + 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( + `${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(path); + const accepted = spawnSync(process.execPath, args, { cwd: root, encoding: 'utf8' }); + 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 ${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, path)), `clean checkout must not contain ${path}`).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. + 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; only after it succeeds, ` + + `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, 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}`, + ].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.each([ + [1, [LEGACY_OWNERSHIP, UNOWNED_MUTABLE_ROUTER_SKILL], []], + [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) => { + 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([ + ['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, []], + ] 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/test/gh-aw-quality.test.ts b/test/gh-aw-quality.test.ts index ed634f12c..27b275ab8 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,11 +1941,13 @@ 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); 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'); @@ -3609,8 +3616,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 +4020,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 +4034,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 +4069,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 +4135,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 +4202,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 +4323,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 +4484,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 +4496,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 +4518,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 +4685,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 +4701,281 @@ 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}.`); + }); + + /** 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(); + moveRecordToNativePath(root, PACKAGE_NAME); + const [legacyPath] = LEGACY_OWNERSHIP_DESTINATIONS; + copyRecord(root, OWNERSHIP_DESTINATION, legacyPath, LEGACY_PACKAGE_NAME); + const failure = verifyInstall(root).failures.join('\n'); + 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'); + 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', () => { + 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} 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}`, + })); + expect(verifyInstall(root).failures.join('\n'), 'an unrelated identity gets no case hint') + .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'], + ] 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(revisionA, true, binding, exactName); + 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 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; + expect(followCleanupAdvice(root, failure, state)).toEqual([variantPath]); + expect(verifyInstall(root, { expectedRevision: revisionA }).failures, `${state} after following the advice`) + .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/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..4a643d83b 100644 --- a/test/squad-cli-latest.test.ts +++ b/test/squad-cli-latest.test.ts @@ -17,6 +17,56 @@ 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); +} + +/** + * 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(); @@ -43,11 +93,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 +131,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', @@ -117,7 +167,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 +181,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', @@ -154,17 +204,45 @@ 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('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( + 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/README.md b/workflows/README.md index 8754c68ad..3712a9af6 100644 --- a/workflows/README.md +++ b/workflows/README.md @@ -4,9 +4,22 @@ 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`, 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. + 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..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" "d12cf4de38e716dd6d0bbb03fda22675477a4559573d3a8601b8f5b2f88f544e" + check_hash "$install_verifier" "514683e50c87644a1bf3977727f84e7bece7a94afc1f5ff5b5cf4c5e3ec2b77e" 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..17d9d7968 100644 --- a/workflows/package/squad-deps-worker.md +++ b/workflows/package/squad-deps-worker.md @@ -646,9 +646,10 @@ 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" + 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 349ca5394..99d8833ad 100644 --- a/workflows/package/squad-implement-worker.md +++ b/workflows/package/squad-implement-worker.md @@ -781,9 +781,10 @@ 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" + 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 438ca7ff7..c774e747d 100644 --- a/workflows/package/squad-retro.md +++ b/workflows/package/squad-retro.md @@ -766,9 +766,10 @@ 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" + 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 e9a82ee41..2652eeb4f 100644 --- a/workflows/package/squad.md +++ b/workflows/package/squad.md @@ -999,9 +999,10 @@ 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" + 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 1ddc96b9f..287d0ec68 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,35 @@ 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)}.`); + } +} + +function isCaseVariantPackageName(packageName) { + return typeof packageName === 'string' && !ACCEPTED_PACKAGE_NAMES.includes(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) { + 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 +413,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 +425,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,7 +775,9 @@ function findOwnershipRecord(root) { const packageDir = safePath(root, '.github/aw/packages'); if (!existsSync(packageDir)) throw new Error('Package ownership metadata is missing.'); const matches = []; - for (const name of readdirSync(packageDir)) { + const caseVariants = []; + // 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; @@ -709,10 +786,38 @@ 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 (isCaseVariantPackageName(record?.package)) { + caseVariants.push({ path: relativePath, record }); + } } - if (matches.length !== 1) { - throw new Error(`Expected exactly one ${PACKAGE_NAME} ownership record; found ${matches.length}.`); + const describeRecords = records => records.map(({ path, record }) => `${path} (${record.package})`).join(', '); + // 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 ${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 === 0) { + // Identity matching stays exact; this only explains a case-only mismatch. + 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}`, + ); } return matches[0].record; } @@ -728,7 +833,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 +842,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 +865,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 +873,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 +881,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 +921,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 +946,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 +1073,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 +1088,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 +1097,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,18 +1116,21 @@ 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. 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], + ['ls-tree', '-z', tree, '--', ...required, REPO_CONFIG_PATH, UNOWNED_MUTABLE_ROUTER_SKILL, ...superseded, + '.github/aw/packages/'], root, ).stdout .split('\0').filter(Boolean); @@ -1042,6 +1158,36 @@ 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 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 ` + + `${result.packageName} installation; do not delete it. Reinstall with ` + + `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.'); + } + // A record deleted only from the working tree still reaches a clean checkout, where + // 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; + let packageName; + try { + packageName = JSON.parse(bytes)?.package; + } catch { + continue; + } + if (isSquadPackageName(packageName)) { + throw new Error( + `${isCaseVariantPackageName(packageName) ? 'Case-variant' : 'Stale'} 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)); } @@ -1064,8 +1210,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 +1258,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}`, @@ -1122,14 +1268,35 @@ 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`, - ` 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}`, + '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'); +} + +function legacyIdentityNotice(packageName, revision) { + return [ + `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`, + ...LEGACY_OWNERSHIP_DESTINATIONS.map(path => `rm -f ${path}`), + ]), ].join('\n'); } @@ -1181,6 +1348,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..6a0cbc50b 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 @@ -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. @@ -537,9 +539,10 @@ 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' + repository: 'github/squad' - name: Initialize Squad team env: diff --git a/workflows/skills/gh-aw-enlistment/SKILL.md b/workflows/skills/gh-aw-enlistment/SKILL.md index 0a02b6d01..3fafef0ff 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 "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 +} +# 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. 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, and installs exactly eight workflows, seventeen runtime resources, and one @@ -226,6 +250,65 @@ 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. 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 +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 the +upgrade is committed, every strict compile repeats that change as four +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.}" +[[ "${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 "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 +} +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 +gh aw compile --strict +node .github/workflows/shared/squad-install-verifier.mjs \ + --verify-install \ + --source-revision "${SQUAD_SHA}" \ + --strict-compile +``` + +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`; five action issues and three dispatches per wake-up remain separate caps. @@ -247,7 +330,7 @@ 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`** > **These are referenced names, not prerequisites.** `gh aw add` lists the secrets @@ -307,11 +390,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 @@ -349,7 +469,23 @@ 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' | + while IFS= read -r path; do + # 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.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 @@ -357,9 +493,16 @@ 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 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`. @@ -498,7 +641,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 "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 +} +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,13 +722,14 @@ 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`. Anything else is a STOP. - ❌ **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 4d3066197..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" "d12cf4de38e716dd6d0bbb03fda22675477a4559573d3a8601b8f5b2f88f544e" + 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 bddad02ad..95baf1d7a 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": "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": "c18889c0fb2dc5a389c04e745bf8398993f07da8a9a1c6511101f3902ed326cd", - "lock_sha256": "079c5ee80259dc9a51f6dd8a3793f8f26586b4adda14078e40b22db7f7f7a73e", - "package_lock_sha256": "bc7611b14ad4cda7b826de290091a36d4e6b8ba05feea6670c75f4bbf3c5e64e" + "source_sha256": "f95265bbca7cb7e5ffd2f0da8bf088186b268e9bf003877e5693566b42ebd0b8", + "lock_sha256": "a45525e04d183caee420286f45a192f86ec2d53e3cefd005669e9ab83fb09b18", + "package_lock_sha256": "4de77bd5b54f10ddc2ba5a90c96412feb042a99e3b20b52b5ab9fb9c046b5277" }, { "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": "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": "d5d34a97409b2fec7be2606ce26eeec5fa675891956331669342155118c259d1", - "lock_sha256": "ad730dedf19602e9f8ca3680b91ab9b992c4b14a2d04feb619576c684ec83376", - "package_lock_sha256": "cabfa606daae655f9fd2ebbb5baeda51767cb33a3b03dffe7f3cf4d1316b1c4a" + "source_sha256": "129a2ed4859d7b145bc1438ad42060e2452b9b400e67b3c9a6f1b34f06e40690", + "lock_sha256": "0eeb649e01dc97ca37992f5a6d9733ddce6700b6d3cd3cd2eb37eaf2966503ae", + "package_lock_sha256": "4735dd7a1ebc531e777d978b0bd344037fc5f2cefe58bc572a7a52b28f12dff6" }, { "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": "fe92a3c7a4402f3f57cf1e041fb78f11ed62312c919271a931773612eceb87bf", + "lock_sha256": "7a9cb513812da3c43e03d14ba06ecff0375d41b3720e64e0d7937001082fd3d0", + "package_lock_sha256": "a3b20ed0fb1823b56ba82144b39eb020448bb8674ad5aa957781a41be54a00b8" }, { "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": "36e56e20eedb07007d81a64ebcae38fffe7441c69ff0dbc792048ea9c5576e48", + "package_lock_sha256": "f744139e41ce8667555d8b6508b30391d4bf2a142bbefc85e418035c95c7dd97" }, { "name": "squad-implement-worker", - "lock_sha256": "8efb5da6e4042953620a73d4b33687202921c97942b9184d54e75f6bc4315b7d", - "package_lock_sha256": "755365ad065a8d843fbc56ea995e9c9fa7e2220dc89540884104c01a292d2f4a" + "lock_sha256": "83d3a3294e624f82c6581e32805a6e78cd9499c46ff9783e1b2935b625fcec92", + "package_lock_sha256": "469fa5f99f2de9321ec92f4dcf36968a80256ad97a65f6ed77affb9e47709471" }, { "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": "ff258df99cc1c92ab632c7a192a174f8125f294bd976c26b15ea91468e585f2d", + "package_lock_sha256": "9e6ee36411a4959fecf966dfae794fde2fbeea9fd7bbdb6a6c6173517db1f1f2" }, { "name": "squad-retro", - "lock_sha256": "1d10cc6eb8bf9387d8d0bb3450dcdaa7d8f60cf5f64d4ac47e14bfaa71feb1fc", - "package_lock_sha256": "4cbe6807df873ad68727a0fbf524cd598f4f4819da116e2b229dbd4b02c0f251" + "lock_sha256": "f7f8297c4e7f832d3ebf316c3163422609eb567876a822e7799524bb18d7a864", + "package_lock_sha256": "b04edb7bb9b921e3309f949650c41160975e082c55ebfb04ed94bb611b7647ce" }, { "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": "e5fbfe1af46967e453d37aa1222d97704f00cc5e52e39cd2070f7cc7538d4222", + "package_lock_sha256": "507b720abf3ec96106a2f1ab21b2fde6cd1088814f06ea6054f3c58ee7291ef4" }, { "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": "514683e50c87644a1bf3977727f84e7bece7a94afc1f5ff5b5cf4c5e3ec2b77e" }, { "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": "cc93d6596b0a88ad916397e1f1481afd16c625a85d38419d00ee44d6dbece031" }, { "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": "a0c3eb2202cf2afdd66e334769c4ce88407d10b7714f86fc869da8efe8c76881" } ], "bootstrap": {