diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 2844312b..94287ebd 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -2,81 +2,10 @@ name: PR Pipeline on: pull_request: branches: [master] +permissions: + contents: read jobs: build-deploy: - runs-on: self-hosted - env: - SCROLL_REGISTRY_PR_NAMESPACE: druid-team-experimental - SCROLL_REGISTRY_ENDPOINT: ${{ secrets.SCROLL_REGISTRY_ENDPOINT }} - SCROLL_REGISTRY_API_KEY: ${{ secrets.SCROLL_REGISTRY_API_KEY }} - SCROLL_REGISTRY_API_SECRET: ${{ secrets.SCROLL_REGISTRY_API_SECRET }} - SCROLL_REGISTRY_BUCKET: ${{ secrets.SCROLL_REGISTRY_BUCKET_STAGING }} - DRUID_CLI_VERSION: v0.1.257 - steps: - - uses: actions/checkout@v3 - - uses: actions/setup-go@v3 - with: - go-version: ">=1.19.3" - - uses: actions/setup-node@v4 - with: - node-version: "22" - cache: npm - cache-dependency-path: ui/package-lock.json - - name: Build and test Scroll UI - working-directory: ui - run: npm ci && npm test && npm run type-check && npm run build - - run: apt update && apt install -y make - - name: Build scroll tree - env: - SCROLL_REGISTRY_HOST: ${{ secrets.SCROLL_REGISTRY_HOST }} - run: | - if [ "${{ github.event.pull_request.head.repo.full_name }}" = "${{ github.repository }}" ]; then - registry_host="${SCROLL_REGISTRY_HOST#http://}" - registry_host="${registry_host#https://}" - registry_host="${registry_host%%/*}" - export DRUID_COLDSTARTER_IMAGE="${registry_host}/${SCROLL_REGISTRY_PR_NAMESPACE}/druid:stable-pr${{ github.event.pull_request.number }}" - fi - make build-tree - - name: Get registry binary - if: github.event.pull_request.head.repo.full_name == github.repository - uses: robinraju/release-downloader@v1.7 - with: - repository: "highcard-dev/druid-cli" - tag: ${{ env.DRUID_CLI_VERSION }} - fileName: "druid" - token: ${{ secrets.GO_REPO_TOKEN }} - - name: Install druid - if: github.event.pull_request.head.repo.full_name == github.repository - run: | - chmod +x druid - mv druid /usr/local/bin/druid - - name: Validate all scrolls - run: ./scripts/validate_all_scrolls.sh && bash ./scripts/validate_ui_coverage.sh - - name: Test bounded push parallelism - run: bash ./scripts/tests/push-parallel.test.sh - - name: Login to registry - id: registry_login - continue-on-error: true - if: github.event.pull_request.head.repo.full_name == github.repository - run: druid login --host ${{ secrets.SCROLL_REGISTRY_HOST }} --user '${{ secrets.SCROLL_REGISTRY_USER }}' --password ${{ secrets.SCROLL_REGISTRY_PASSWORD }} - - name: Check scroll changes - id: scroll-changes - run: | - set -euo pipefail - git fetch origin "${{ github.base_ref }}" --depth=1 - if git diff --quiet "origin/${{ github.base_ref }}"..HEAD -- scrolls; then - echo "changed=false" >> "$GITHUB_OUTPUT" - echo "No scroll source changes; skipping preview push." - else - echo "changed=true" >> "$GITHUB_OUTPUT" - echo "Scroll source changed; publishing preview tags." - fi - - name: Push experimental PR tags - if: github.event.pull_request.head.repo.full_name == github.repository && steps.scroll-changes.outputs.changed == 'true' && steps.registry_login.outcome == 'success' - env: - SCROLL_REGISTRY_HOST: ${{ secrets.SCROLL_REGISTRY_HOST }} - SCROLL_REGISTRY_NAMESPACE: druid-team-experimental - SCROLL_REGISTRY_RUNTIME_NAMESPACE: druid-team - SCROLL_TAG_SUFFIX: -pr${{ github.event.pull_request.number }} - SCROLL_PUSH_CATEGORIES: "0" - run: bash ./scripts/push.sh + uses: ./.github/workflows/scroll-lifecycle.yml + with: + public: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ea52735d..237951e9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,47 +2,12 @@ name: Release Changed Pipeline on: workflow_dispatch: push: - branches: - - master - tags: - - v* + branches: [master] + tags: [v*] +permissions: + contents: read jobs: build-deploy: - runs-on: self-hosted - env: - SCROLL_REGISTRY_ENDPOINT: ${{ secrets.SCROLL_REGISTRY_ENDPOINT }} - SCROLL_REGISTRY_API_KEY: ${{ secrets.SCROLL_REGISTRY_API_KEY }} - SCROLL_REGISTRY_API_SECRET: ${{ secrets.SCROLL_REGISTRY_API_SECRET }} - SCROLL_REGISTRY_BUCKET: ${{ secrets.SCROLL_REGISTRY_BUCKET_STAGING }} - SCROLL_REGISTRY_HOST: ${{ secrets.SCROLL_REGISTRY_HOST }} - SCROLL_REGISTRY_USER: ${{ secrets.SCROLL_REGISTRY_USER }} - SCROLL_REGISTRY_PASSWORD: ${{ secrets.SCROLL_REGISTRY_PASSWORD }} - DRUID_CLI_VERSION: v0.1.257 - steps: - - uses: actions/checkout@v3 - - uses: actions/setup-go@v3 - with: - go-version: ">=1.19.3" - - uses: actions/setup-node@v4 - with: - node-version: "22" - cache: npm - cache-dependency-path: ui/package-lock.json - - name: Build and test Scroll UI - working-directory: ui - run: npm ci && npm test && npm run type-check && npm run build - - name: Get registry binary - uses: robinraju/release-downloader@v1.7 - with: - repository: "highcard-dev/druid-cli" - tag: ${{ env.DRUID_CLI_VERSION }} - fileName: "druid" - token: ${{ secrets.GO_REPO_TOKEN }} - - run: chmod +x druid - - name: Install druid - run: mv druid /usr/local/bin/druid - - name: druid version - run: druid version - - run: ./scripts/validate_all_scrolls.sh && bash ./scripts/validate_ui_coverage.sh - - name: Push release scrolls - run: bash ./scripts/push.sh + uses: ./.github/workflows/scroll-lifecycle.yml + with: + public: true diff --git a/.github/workflows/scroll-lifecycle.yml b/.github/workflows/scroll-lifecycle.yml new file mode 100644 index 00000000..592812a9 --- /dev/null +++ b/.github/workflows/scroll-lifecycle.yml @@ -0,0 +1,130 @@ +name: Shared Scroll lifecycle +on: + workflow_call: + inputs: + public: + description: Publish reviewed releases publicly; PR previews remain private. + required: true + type: boolean +permissions: + contents: read + +jobs: + verify: + runs-on: self-hosted + outputs: + changed: ${{ steps.changes.outputs.changed }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + fetch-depth: 0 + - uses: actions/setup-go@v5 + with: + go-version: "1.24.7" + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: ui/package-lock.json + - name: Build and test Scroll UI + working-directory: ui + run: npm ci && npm test && npm run type-check && npm run build + - name: Build scroll tree + if: github.event_name == 'pull_request' + run: make build-tree + - name: Validate catalog and UI + run: go run ./scripts/validate-release-workflow && go run ./scripts/validate-scrolls.go && bash ./scripts/validate_ui_coverage.sh + - name: Test lifecycle publication + run: go test ./scripts/publish-lifecycle ./scripts/stage-scroll-ui ./scripts/validate-release-workflow -count=1 + - name: Test bounded catalog publication + run: bash ./scripts/tests/push-parallel.test.sh + - name: Check release inputs + id: changes + env: + EVENT_NAME: ${{ github.event_name }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + set -euo pipefail + if [[ "$EVENT_NAME" == "pull_request" ]] && git diff --quiet "$BASE_SHA"...HEAD -- scrolls ui scripts .github/workflows go.mod go.sum Makefile; then + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + publish: + needs: verify + if: >- + needs.verify.outputs.changed == 'true' && + ((inputs.public && github.event_name != 'pull_request') || + (!inputs.public && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)) + # A fresh runner keeps Team credentials out of the untrusted validation job. + runs-on: ubuntu-latest + timeout-minutes: 360 + environment: ${{ inputs.public && 'scroll-release' || 'scroll-preview' }} + concurrency: + group: scroll-lifecycle-${{ inputs.public && 'release' || format('pr-{0}', github.event.pull_request.number) }} + cancel-in-progress: false + steps: + - name: Require provisioned lifecycle environment + env: + LIFECYCLE_READY: ${{ vars.SCROLL_LIFECYCLE_READY }} + run: | + if [[ "$LIFECYCLE_READY" != "true" ]]; then + echo "Configure the protected Team lifecycle environment before publication." >&2 + exit 1 + fi + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + - name: Checkout lifecycle-capable CLI + uses: actions/checkout@v4 + with: + repository: highcard-dev/druid-cli + # Includes SOURCE_DATE_EPOCH and stable root-layer ordering. + ref: a9f5dd9ec361ee5b268dfa5b5c2a56ef60f350fd + path: .druid-cli + token: ${{ secrets.GO_REPO_TOKEN }} + persist-credentials: false + - uses: actions/setup-go@v5 + with: + go-version-file: .druid-cli/go.mod + - name: Build pinned CLI + working-directory: .druid-cli + run: | + mkdir -p "$RUNNER_TEMP/scroll-cli" + go build -o "$RUNNER_TEMP/scroll-cli/druid" ./apps/druid + echo "$RUNNER_TEMP/scroll-cli" >> "$GITHUB_PATH" + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: ui/package-lock.json + - name: Build Scroll UI + working-directory: ui + run: npm ci && npm run build + - name: Build scroll tree + if: github.event_name == 'pull_request' + run: make build-tree + - name: Validate with pinned CLI + run: ./scripts/validate_all_scrolls.sh && bash ./scripts/validate_ui_coverage.sh + - name: Publish through shared lifecycle + env: + SCROLL_PUBLISH_MODE: lifecycle + SCROLL_LIFECYCLE_URL: ${{ vars.SCROLL_LIFECYCLE_URL }} + SCROLL_AUTH_URL: ${{ vars.SCROLL_AUTH_URL }} + SCROLL_LIFECYCLE_OWNER: ${{ vars.SCROLL_LIFECYCLE_OWNER }} + SCROLL_REGISTRY_HOST: ${{ vars.SCROLL_REGISTRY_HOST }} + SCROLL_REGISTRY_NAMESPACE: ${{ vars.SCROLL_LIFECYCLE_OWNER }} + SCROLL_REGISTRY_RUNTIME_NAMESPACE: druid-team + SCROLL_REGISTRY_USER: ${{ secrets.SCROLL_TEAM_REGISTRY_USER }} + SCROLL_REGISTRY_PASSWORD: ${{ secrets.SCROLL_TEAM_REGISTRY_PASSWORD }} + SCROLL_TEAM_EMAIL: ${{ secrets.SCROLL_TEAM_EMAIL }} + SCROLL_TEAM_PASSWORD: ${{ secrets.SCROLL_TEAM_PASSWORD }} + SCROLL_TAG_SUFFIX: -${{ github.event.pull_request.head.sha || github.sha }} + SCROLL_LIFECYCLE_REPOSITORY_SUFFIX: ${{ github.event_name == 'pull_request' && format('-pr{0}', github.event.pull_request.number) || '' }} + SCROLL_LIFECYCLE_PUBLISH: ${{ inputs.public && '1' || '0' }} + SCROLL_LIFECYCLE_REVIEWED: ${{ inputs.public && '1' || '0' }} + run: bash ./scripts/push.sh diff --git a/docs/shared-lifecycle-publication.md b/docs/shared-lifecycle-publication.md new file mode 100644 index 00000000..fd52626f --- /dev/null +++ b/docs/shared-lifecycle-publication.md @@ -0,0 +1,161 @@ +# Shared authored-release publication + +The local workflow draft routes release and PR publication through the shared +lifecycle. This is a local-only recovery; it has not been pushed or run on GitHub. +Dedicated Team +identity/credentials and protected GitHub environments are not provisioned. +Do not treat this as deployed or as complete product acceptance. + +## Local CI cutover draft + +`release.yml` and `pr.yml` call `scroll-lifecycle.yml`; neither retains a direct +registry publication fallback. The shared workflow validates/builds UI, checks +the catalog and runs publisher/staging/workflow tests without Team secrets. +Publication then uses a fresh runner and the protected `scroll-release` or +`scroll-preview` environment. Fork PRs validate but never enter publication. + +The publication job builds CLI commit +`a9f5dd9ec361ee5b268dfa5b5c2a56ef60f350fd`, which includes fixed-source timestamps +and deterministic layer order, rather than assuming the old released binary has +those behaviors. It checks out the exact release/PR-head SHA, suffixes revisions +with that SHA, and uses separate `-pr` private repositories for previews. +Runtime-image references remain separate from the private staging project. +In-flight publication is not cancelled when a newer run arrives. + +The existing direct script mode remains available for local Harbor bootstrap; +neither CI caller selects it. CI still uses the one explicit catalog and its +category barrier. Categories go into the publisher's private staging project; +final revisions embed their own presentation metadata. + +## Rollout prerequisites (no remote changes performed) + +Before enabling the draft: + +1. Deploy matching Core/runtime lifecycle changes through their normal CI. +2. Create a normal dedicated Team account and issue a private-project robot + through the normal registry-credentials flow. Do not reuse the old admin or + public-project robot. +3. Create `scroll-release` and `scroll-preview` GitHub environments. Require + approval of the exact source commit and disable self-approval. Restrict release + deployment branches/tags to the reviewed release sources. Preview approval + must cover source code that will receive the dedicated preview credentials. +4. Put `GO_REPO_TOKEN` (read-only CLI source access), `SCROLL_TEAM_EMAIL`, + `SCROLL_TEAM_PASSWORD`, `SCROLL_TEAM_REGISTRY_USER` and + `SCROLL_TEAM_REGISTRY_PASSWORD` in those environments, not validation jobs. + Set environment variables `SCROLL_LIFECYCLE_URL`, `SCROLL_AUTH_URL`, + `SCROLL_LIFECYCLE_OWNER` and scheme-less `SCROLL_REGISTRY_HOST`. +5. Only after checking protections and deployed dependencies, set the environment + variable `SCROLL_LIFECYCLE_READY=true`. A missing/false value fails the job; + it does not skip publication successfully or fall back to direct pushes. +6. Review branch-protection required-check names after the shared-workflow change, + and run the first approved preview/release with fresh credentials. + +Workflow YAML names an environment but does not create its required-reviewer +policy. Environment secrets belong to the called job, as described in +[GitHub's reusable-workflow documentation](https://docs.github.com/en/actions/how-tos/reuse-automations/reuse-workflows#using-inputs-and-secrets-in-a-reusable-workflow). + +## Contract + +`scripts/push.sh` remains the single explicit catalog and retains its category +barrier, serial default and bounded concurrency. `SCROLL_PUBLISH_MODE=lifecycle` +adds the shared Core policy after each rendered artifact push: + +1. Verify the authenticated publisher's private project against the configured + owner and registry. Reject admin/public-project registry credentials. +2. Render/stage the existing Scroll and private UI. Include inherited family + `.meta` in the revision, not just a mutable category artifact. +3. Push into that private project with a cryptographically unique staging tag. +4. Resolve that tag once, then import its immutable digest through Core. +5. Core applies canonical identity before hashing, verifies final bytes, handles + immutable-tag conflicts and refreshes the catalog under its repository lock. +6. When explicitly reviewed, publish through the same repository-wide operation + used by customers. No public-registry credentials or Team bypass are involved. + +The source staging artifact is retained. Backup snapshots are rejected by the +authored-release endpoint; they must use backup promotion with provenance. + +## Configuration contract (not provisioned) + +- `SCROLL_LIFECYCLE_URL`: trusted Core base URL, including `/core` at the gateway. +- `SCROLL_LIFECYCLE_OWNER`: identity ID of the dedicated publisher account. +- `SCROLL_REGISTRY_HOST`: scheme-less configured Harbor host. +- `SCROLL_REGISTRY_NAMESPACE`: must equal that identity ID (private staging). +- `SCROLL_REGISTRY_USER` / `SCROLL_REGISTRY_PASSWORD`: robot credential belonging + to that private project, issued by the normal account registry-credential flow. +- `SCROLL_AUTH_URL`, `SCROLL_TEAM_EMAIL`, `SCROLL_TEAM_PASSWORD`: normal dedicated + account sign-in. Auth must have the same trusted origin as Core. Session cookies + stay in memory, owner JWTs refresh before lifecycle calls, and sign-out is + attempted at exit. Auth credentials are not passed to the renderer subprocess. +- Alternatively `SCROLL_LIFECYCLE_TOKEN`: a fresh externally supplied owner JWT; + expiration fails closed. Never commit or print credentials. +- `SCROLL_REGISTRY_RUNTIME_NAMESPACE=druid-team`: keeps runtime images separate + from the private staging project. +- `SCROLL_TAG_SUFFIX=-`: immutable revision names for repeatable CI. + Reusing a tag with changed content is a conflict, not an overwrite. +- Lifecycle mode sets `SOURCE_DATE_EPOCH` from the checked-out commit unless + explicitly supplied. The matching CLI must support it and deterministic root + layer ordering: otherwise identical rebuilds correctly conflict as different + manifest bytes. +- `SCROLL_LIFECYCLE_PUBLISH=1` plus `SCROLL_LIFECYCLE_REVIEWED=1`: explicitly + reviewed public publication. Without publish, imports remain private unless + the destination was already public, in which case unreviewed import fails. +- `SCROLL_LIFECYCLE_REPOSITORY_SUFFIX=-pr`: **separate private preview + repositories**. A PR tag in an already-public release repository is not private. + +Keep secrets restricted to trusted publication jobs and require a release review +gate. No production account, robot, GitHub secret, or environment has been created +by this implementation. Both Core import support and the matching runtime/server +lifecycle PRs must be deployed before enabling the workflows. + +## Verification + +- Recovery on 2026-10-03 reconstructed the latest retained changes from session + history in `druid-local/.worktrees/scroll-lifecycle/scrolls`, on the local + branch `recovery/scroll-lifecycle-20261003`. Publisher, UI-staging and workflow + suites pass freshly, as do workflow validation and actionlint. The earlier + live acceptance results below are historical, not rerun during recovery. +- Local workflow syntax checks pass with actionlint v1.7.7 (ShellCheck disabled). + Publisher, UI-staging and workflow-validator Go suites pass together. + Twelve workflow mutation cases reject missing cutover guards; executing the + actual provisioning guard proves unset/false/uppercase values fail and only + `true` passes. The guard must precede every other publication-job step. + Independent spec/standards review found no blocking defects; the standards + review prompted the executable provisioning-guard regression test. + This verifies the local draft, not GitHub environment protection or live CI. +- `go test ./scripts/publish-lifecycle -run '^TestPushScript' -count=3`: + passes (37.753s). The real `push.sh` runs against offline boundaries, covering + all category/artifact calls, immutable timestamp/tag inputs, separate runtime + image namespace, lifecycle artifact concurrency, category barrier, preflight + rejection before mutation and complete job reaping after a publisher failure. + This does not exercise live Core or prove that CI selects lifecycle mode. +- Go publisher tests cover shared private/public operations, immutable staging, + expected-owner rejection before push, failed-push isolation, separate private + preview identity, explicit review, fresh JWTs and redirect credential isolation. +- Metadata staging tests check inherited presentation and the existing private UI. +- `SCROLL_PUSH_UI=0 bash scripts/tests/push-parallel.test.sh`: passed. +- Full staging-enabled concurrency run: passed, 18 categories / 127 artifacts. + One earlier run during concurrent edits stopped early (47 artifacts); its + original staging error was not retained. The rerun passed without a pool fix; + do not misreport that earlier result as a diagnosed production defect. +- Core shared policy/adapter/schema tests and real local Harbor/Postgres tests + cover authored imports, immutable conflicts, public review guards, byte + preservation and private staging. See the monorepo verification document. +- Authenticated helper → local gateway/auth → live Core → Harbor acceptance + passed in 7.10s, including private import, an identical digest on rebuild, + shared publication and anonymous visibility. The fixture's robot, projects, + catalog row, auth account/session and development credit were cleaned up. + Four retained fixtures from earlier diagnosis attempts were also removed with + exact identity/name/email guards. CI credential provisioning and rollout are + still outstanding; no production account or registry was touched. +- Earlier live failures identified fixture username/schema mistakes and a real + ORAS creation-timestamp reproducibility gap. The timestamp fix preserves + immutable-tag conflicts rather than weakening them. +- Subsequent retry failures exposed nondeterministic root-layer ordering in the + CLI. A focused real OCI push regression reproduced it in 0.08s; sorting the + discovered paths fixed the byte difference without weakening conflict checks. + The final live test explicitly signs out after signup and signs back in with + the dedicated fixture account. Three consecutive runs passed (8.53s, 6.25s, + 6.16s), including exact retry, publication, anonymous visibility and cleanup. + +On interruption, inspect final registry state before retrying. A disconnected +HTTP request does not prove that an import or publication was rolled back. diff --git a/scripts/publish-lifecycle/local_integration_test.go b/scripts/publish-lifecycle/local_integration_test.go new file mode 100644 index 00000000..1d7a4d67 --- /dev/null +++ b/scripts/publish-lifecycle/local_integration_test.go @@ -0,0 +1,206 @@ +package main + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "net/http/cookiejar" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "testing" + "time" +) + +// Opt-in localhost only. Creates an isolated publisher account/project and one +// tiny Scroll. It neither deploys a workload nor consumes shared game ports. +func TestLocalAuthenticatedPublisher(t *testing.T) { + if os.Getenv("SCROLL_LOCAL_LIFECYCLE_TEST") != "1" { + t.Skip("local live stack opt-in") + } + bin := os.Getenv("DRUID_BIN") + if bin == "" { + t.Fatal("DRUID_BIN is required") + } + jar, _ := cookiejar.New(nil) + p := &publisher{base: "http://localhost:3000/api/core", authURL: "http://localhost:3000/api/auth/v2", host: "druid-gs:8088", client: &http.Client{Jar: jar, Timeout: 2 * time.Minute, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}} + nonce := make([]byte, 16) + if _, err := rand.Read(nonce); err != nil { + t.Fatal(err) + } + suffix := hex.EncodeToString(nonce) + var account struct { + User struct { + ID string `json:"id"` + } `json:"user"` + } + if err := p.authRequest(http.MethodPost, "/sign-up/email", map[string]any{"email": "lifecycle-" + suffix + "@example.invalid", "password": suffix + "-Fixture!", "name": "Local lifecycle acceptance", "username": "lifecycle" + suffix[:12]}, &account); err != nil { + t.Fatal(err) + } + p.owner = account.User.ID + if p.owner == "" { + t.Fatal("signup returned no fixture identity") + } + t.Logf("local fixture identity: %s", p.owner) + t.Cleanup(func() { cleanupLocalPublisher(t, p.owner) }) + t.Cleanup(func() { _ = p.authRequest(http.MethodPost, "/sign-out", map[string]any{}, nil) }) + // Exercise the same normal dedicated-account sign-in used by CI, not just + // the initial signup session returned by fixture setup. + if err := p.authRequest(http.MethodPost, "/sign-out", map[string]any{}, nil); err != nil { + t.Fatal(err) + } + if err := p.authRequest(http.MethodPost, "/sign-in/email", map[string]string{"email": "lifecycle-" + suffix + "@example.invalid", "password": suffix + "-Fixture!"}, nil); err != nil { + t.Fatal(err) + } + if err := p.preflight(); err != nil { + t.Fatal(err) + } + var robot struct { + ID int `json:"id"` + Username string `json:"username"` + Password string `json:"password"` + } + if err := p.request(http.MethodPost, "/v1/registry/credentials", map[string]string{"name": "lifecycle-fixture"}, &robot); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := p.request(http.MethodDelete, fmt.Sprintf("/v1/registry/credentials/%d", robot.ID), nil, nil); err != nil { + t.Error("fixture credential revocation failed") + } + }) + cliHome := t.TempDir() + run := func(args []string) error { + cmd := exec.Command(args[0], args[1:]...) + for _, value := range os.Environ() { + if !strings.HasPrefix(value, "HOME=") { + cmd.Env = append(cmd.Env, value) + } + } + cmd.Env = append(cmd.Env, "HOME="+cliHome, "DRUID_REGISTRY_PLAIN_HTTP=true", "SOURCE_DATE_EPOCH=0") + output, err := cmd.CombinedOutput() + if err != nil && len(args) > 1 && args[1] != "login" { + // Fixture-only diagnostics: never print auth/login output or full logs. + for _, line := range strings.Split(string(output), "\n") { + if strings.HasPrefix(line, "Error:") { + t.Log(strings.ReplaceAll(strings.ReplaceAll(line, robot.Password, ""), p.token, "")) + } + } + } + return err + } + if err := run([]string{bin, "login", "--host", p.host, "--user", robot.Username, "--password", robot.Password}); err != nil { + t.Fatal("fixture registry login failed") + } + source := t.TempDir() + if err := os.Mkdir(filepath.Join(source, ".meta"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(source, ".meta", "en-US.md"), []byte("---\nname: Local authored fixture\n---\nNo customer content.\n"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(source, "scroll.yaml"), []byte("name: fixture\ndesc: Local authored fixture\nversion: 1.0.0\napp_version: '1.0.0'\ncommands:\n start:\n run: persistent\n procedures:\n - id: fixture\n command: [sleep, '600']\n image: busybox:1.36\n"), 0644); err != nil { + t.Fatal(err) + } + command := []string{bin, "push", p.host + "/" + p.owner + "/example:accepted-fixture", source, "--category", "fixture"} + if err := p.publish(command, run); err != nil { + t.Fatal(err) + } + // Repeat exactly through the real authenticated public operation. It must + // verify the existing finalized bytes, not overwrite the revision tag. + p.public = true + if err := p.publish(command, run); err != nil { + t.Fatal(err) + } + t.Log("authenticated private staging, exact retry and shared publication passed") + // Assert publication through Harbor's public metadata without owner cookies. + projectName := fmt.Sprintf("scroll-%x-example", sha256.Sum256([]byte(p.owner))) + response, err := http.Get("http://druid-gs:8088/api/v2.0/projects?page_size=100&name=" + projectName) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Fatal("anonymous public project listing failed") + } + var projects []struct { + Name string `json:"name"` + Metadata struct { + Public string `json:"public"` + } `json:"metadata"` + } + if err := json.NewDecoder(response.Body).Decode(&projects); err != nil { + t.Fatal(err) + } + found := false + for _, project := range projects { + if project.Name == projectName && project.Metadata.Public == "true" { + found = true + } + } + if !found { + t.Fatal("public fixture was not listed") + } +} + +func cleanupLocalPublisher(t *testing.T, owner string) { + t.Helper() + if !regexp.MustCompile(`^[a-f0-9-]{36}$`).MatchString(owner) { + t.Error("invalid fixture owner; cleanup refused") + return + } + fixture := fmt.Sprintf(`SELECT id::text FROM auth."user" WHERE id='%s' AND name='Local lifecycle acceptance' AND email ~ '^lifecycle-[a-f0-9]{32}@example[.]invalid$'`, owner) + verified, err := exec.Command("docker", "exec", "druid-postgres", "psql", "-U", "druid", "-d", "druid", "-At", "-c", fixture).Output() + if err != nil || strings.TrimSpace(string(verified)) != owner { + t.Error("exact local fixture identity could not be verified; cleanup refused") + return + } + canonicalProject := fmt.Sprintf("scroll-%x-example", sha256.Sum256([]byte(owner))) + for _, project := range []string{owner, canonicalProject} { + for _, path := range []string{"/api/v2.0/projects/" + project + "/repositories/example", "/api/v2.0/projects/" + project} { + req, _ := http.NewRequest(http.MethodDelete, "http://druid-gs:8088"+path, nil) + password := os.Getenv("LOCAL_HARBOR_TEST_PASSWORD") + if password == "" { + password = "admin" + } + req.SetBasicAuth("admin", password) + req.Header.Set("X-Is-Resource-Name", "true") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Error("local Harbor fixture cleanup failed") + return + } + resp.Body.Close() + if resp.StatusCode != 404 && (resp.StatusCode < 200 || resp.StatusCode >= 300) { + t.Errorf("local Harbor fixture cleanup HTTP %d", resp.StatusCode) + return + } + } + } + // Only this generated fixture identity is eligible. FK failures roll back + // the entire database cleanup; unrelated/user-owned rows are never targeted. + sql := fmt.Sprintf(`BEGIN; +DELETE FROM scroll.repository WHERE user_id::text IN (%s); +DELETE FROM core.ledger_entry WHERE user_id::text IN (%s); +DELETE FROM core."user" WHERE identity_id::text IN (%s); +DELETE FROM auth."user" WHERE id::text IN (%s); +COMMIT;`, fixture, fixture, fixture, fixture) + cmd := exec.Command("docker", "exec", "druid-postgres", "psql", "-U", "druid", "-d", "druid", "-v", "ON_ERROR_STOP=1", "-c", sql) + if err := cmd.Run(); err != nil { + t.Error("fixture database cleanup failed; exact identity retained:", owner) + } +} + +func TestCleanupRetainedLocalPublisherFixtures(t *testing.T) { + ids := os.Getenv("SCROLL_LOCAL_FIXTURE_CLEANUP") + if ids == "" { + t.Skip("explicit retained-fixture IDs required") + } + for _, id := range strings.Split(ids, ",") { + cleanupLocalPublisher(t, id) + } +} diff --git a/scripts/publish-lifecycle/main.go b/scripts/publish-lifecycle/main.go new file mode 100644 index 00000000..264e6303 --- /dev/null +++ b/scripts/publish-lifecycle/main.go @@ -0,0 +1,266 @@ +// Publishes the existing explicit catalog through the shared Core lifecycle. +// Registry credentials can write private staging only; Core owns finalization. +package main + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/cookiejar" + "net/url" + "os" + "os/exec" + "regexp" + "strings" + "time" +) + +type publisher struct { + base, token, owner, host, repositorySuffix string + public bool + client *http.Client + authURL string +} + +var exact = regexp.MustCompile(`^[^@\s]+@sha256:[a-f0-9]{64}$`) + +func configured() (*publisher, error) { + base := strings.TrimRight(os.Getenv("SCROLL_LIFECYCLE_URL"), "/") + u, err := url.Parse(base) + if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Scheme != "https" && !(u.Scheme == "http" && (u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1" || u.Hostname() == "druid-gs"))) { + return nil, errors.New("SCROLL_LIFECYCLE_URL must be HTTPS (HTTP allowed only for local development)") + } + p := &publisher{base: base, token: os.Getenv("SCROLL_LIFECYCLE_TOKEN"), owner: os.Getenv("SCROLL_LIFECYCLE_OWNER"), host: os.Getenv("SCROLL_REGISTRY_HOST"), repositorySuffix: os.Getenv("SCROLL_LIFECYCLE_REPOSITORY_SUFFIX"), public: os.Getenv("SCROLL_LIFECYCLE_PUBLISH") == "1", + client: &http.Client{Timeout: 70 * time.Minute, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}} + if p.owner == "" || p.host == "" || os.Getenv("SCROLL_REGISTRY_NAMESPACE") != p.owner { + return nil, errors.New("expected owner, registry host and matching private staging namespace are required") + } + if p.public && os.Getenv("SCROLL_LIFECYCLE_REVIEWED") != "1" { + return nil, errors.New("public publication requires SCROLL_LIFECYCLE_REVIEWED=1 after content review") + } + // Never reuse production admin/public-project credentials for staging. + if !strings.HasPrefix(os.Getenv("SCROLL_REGISTRY_USER"), "robot$"+p.owner+"+") { + return nil, errors.New("registry credentials must belong to the expected owner's private project") + } + if p.token == "" { + auth, err := url.Parse(strings.TrimRight(os.Getenv("SCROLL_AUTH_URL"), "/")) + if err != nil || auth.Host != u.Host || auth.Scheme != u.Scheme || auth.User != nil || auth.RawQuery != "" || auth.Fragment != "" { + return nil, errors.New("SCROLL_AUTH_URL must use the same trusted origin as Core") + } + email, password := os.Getenv("SCROLL_TEAM_EMAIL"), os.Getenv("SCROLL_TEAM_PASSWORD") + if email == "" || password == "" { + return nil, errors.New("provide a fresh lifecycle token or dedicated Team sign-in credentials") + } + p.authURL = auth.String() + p.client.Jar, _ = cookiejar.New(nil) + if err := p.authRequest(http.MethodPost, "/sign-in/email", map[string]string{"email": email, "password": password}, nil); err != nil { + return nil, err + } + } + return p, nil +} + +// Use normal account sign-in and refreshed JWTs, not a Team-specific auth bypass. +// Session cookies stay in memory and are revoked on exit. +func (p *publisher) authRequest(method, path string, body, out any) error { + data, err := json.Marshal(body) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, method, p.authURL+path, bytes.NewReader(data)) + if err != nil { + return errors.New("invalid auth request") + } + req.Header.Set("Content-Type", "application/json") + if origin, err := url.Parse(p.authURL); err == nil { + req.Header.Set("Origin", origin.Scheme+"://"+origin.Host) + } + resp, err := p.client.Do(req) + if err != nil { + return errors.New("publisher authentication failed") + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + var failure struct { + Code string `json:"code"` + } + _ = json.NewDecoder(io.LimitReader(resp.Body, 1024)).Decode(&failure) + if !regexp.MustCompile(`^[A-Z_]{1,64}$`).MatchString(failure.Code) { + failure.Code = "AUTH_FAILED" + } + return fmt.Errorf("publisher authentication rejected: HTTP %d (%s)", resp.StatusCode, failure.Code) + } + if out != nil { + return json.NewDecoder(io.LimitReader(resp.Body, 1024*1024)).Decode(out) + } + return nil +} + +func (p *publisher) request(method, path string, body, out any) error { + if p.authURL != "" { + var jwt struct { + Token string `json:"token"` + } + if err := p.authRequest(http.MethodGet, "/token", nil, &jwt); err != nil { + return err + } + if jwt.Token == "" { + return errors.New("authentication returned no owner JWT") + } + p.token = jwt.Token + } + var data []byte + var err error + if body != nil { + data, err = json.Marshal(body) + if err != nil { + return err + } + } + req, err := http.NewRequest(method, p.base+path, bytes.NewReader(data)) + if err != nil { + return errors.New("invalid lifecycle request") + } + req.Header.Set("Authorization", "Bearer "+p.token) + req.Header.Set("Content-Type", "application/json") + resp, err := p.client.Do(req) + if err != nil { + return errors.New("lifecycle request failed; inspect installed/published state before retrying") + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("lifecycle request rejected: HTTP %d", resp.StatusCode) + } + if out != nil { + return json.NewDecoder(io.LimitReader(resp.Body, 1024*1024)).Decode(out) + } + return nil +} + +func (p *publisher) preflight() error { + var project struct { + Registry string `json:"registry"` + Project string `json:"project"` + } + // The shared operation ensures this is the authenticated actor's PRIVATE + // project. A supplied owner name alone is never treated as authorization. + if err := p.request(http.MethodGet, "/v1/registry/project", nil, &project); err != nil { + return err + } + if project.Registry != p.host || project.Project != p.host+"/"+p.owner { + return errors.New("authenticated identity or registry differs from the expected publisher") + } + return nil +} + +func (p *publisher) publish(command []string, run func([]string) error) error { + if len(command) < 4 || command[1] != "push" || command[2] == "category" { + return errors.New("expected a rendered artifact push command") + } + ref := command[2] + prefix := p.host + "/" + p.owner + "/" + if !strings.HasPrefix(ref, prefix) { + return errors.New("artifact must target the verified private staging project") + } + nameTag := strings.TrimPrefix(ref, prefix) + parts := strings.Split(nameTag, ":") + if len(parts) != 2 || strings.Contains(parts[0], "/") || !regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,62}$`).MatchString(parts[0]) || !regexp.MustCompile(`^[\w][\w.-]{0,127}$`).MatchString(parts[1]) { + return errors.New("invalid repository or explicit revision tag") + } + name := parts[0] + p.repositorySuffix + if !regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,62}$`).MatchString(name) { + return errors.New("invalid final repository name") + } + if err := p.preflight(); err != nil { + return err + } + nonce := make([]byte, 16) + if _, err := rand.Read(nonce); err != nil { + return err + } + // Each invocation owns its staging tag, so another job cannot replace the + // mutable selection between our push and the single digest resolution. + stagedRef := prefix + parts[0] + ":build-" + hex.EncodeToString(nonce) + stagedCommand := append([]string(nil), command...) + stagedCommand[2] = stagedRef + // Final reusable revisions embed presentation instead of depending on a + // separately mutable category artifact. Staging supplies inherited .meta. + stagedCommand = append(stagedCommand, "--pack-meta") + if err := run(stagedCommand); err != nil { + return err + } + var staged struct { + Artifact string `json:"artifact"` + } + if err := p.request(http.MethodGet, "/v1/registry/scrolls/revision?artifact="+url.QueryEscape(stagedRef), nil, &staged); err != nil { + return err + } + if !exact.MatchString(staged.Artifact) || !strings.HasPrefix(staged.Artifact, prefix+parts[0]+"@") { + return errors.New("resolved staging revision differs from selected repository") + } + var imported struct { + Artifact string `json:"artifact"` + } + input := map[string]any{"repository": name, "tag": parts[1], "releaseArtifact": staged.Artifact, "reviewedPublicContent": p.public} + if err := p.request(http.MethodPost, "/v1/registry/scrolls/releases/import", input, &imported); err != nil { + return err + } + ownerHash := sha256.Sum256([]byte(p.owner)) + canonical := fmt.Sprintf("%s/scroll-%x-%s/%s", p.host, ownerHash, name, name) + if !exact.MatchString(imported.Artifact) || !strings.HasPrefix(imported.Artifact, canonical+"@") { + return errors.New("Core did not return an exact finalized revision") + } + if p.public { + digest := strings.SplitN(imported.Artifact, "@", 2)[1] + if err := p.request(http.MethodPost, "/v1/registry/scrolls/publish", map[string]any{"repository": name, "revision": digest, "reviewedPublicContent": true}, nil); err != nil { + return err + } + } + fmt.Println(imported.Artifact) + return nil +} + +func runPublisher() error { + p, err := configured() + if err == nil && p.authURL != "" { + defer p.authRequest(http.MethodPost, "/sign-out", map[string]any{}, nil) + } + if err == nil { + if len(os.Args) == 2 && os.Args[1] == "preflight" { + err = p.preflight() + } else { + args := os.Args[1:] + if len(args) > 0 && args[0] == "--" { + args = args[1:] + } + err = p.publish(args, func(command []string) error { + cmd := exec.Command(command[0], command[1:]...) + for _, entry := range os.Environ() { + if strings.HasPrefix(entry, "SCROLL_TEAM_PASSWORD=") || strings.HasPrefix(entry, "SCROLL_TEAM_EMAIL=") || strings.HasPrefix(entry, "SCROLL_LIFECYCLE_TOKEN=") { + continue + } + cmd.Env = append(cmd.Env, entry) + } + cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr + return cmd.Run() + }) + } + } + return err +} + +func main() { + if err := runPublisher(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/scripts/publish-lifecycle/main_test.go b/scripts/publish-lifecycle/main_test.go new file mode 100644 index 00000000..23f69a68 --- /dev/null +++ b/scripts/publish-lifecycle/main_test.go @@ -0,0 +1,218 @@ +package main + +import ( + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestSharedLifecyclePrivateStagingAndExplicitPublication(t *testing.T) { + for _, public := range []bool{false, true} { + t.Run(fmt.Sprint(public), func(t *testing.T) { + var staged string + var imported, published int + name := "scroll-example" + suffix := "" + if !public { + suffix = "-pr12" + } + name += suffix + canonical := fmt.Sprintf("registry.test/scroll-%x-%s/%s", sha256.Sum256([]byte("team-owner")), name, name) + digest := "sha256:" + strings.Repeat("a", 64) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer fixture" { + t.Error("owner authorization missing") + } + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/v1/registry/project": + fmt.Fprint(w, `{"registry":"registry.test","project":"registry.test/team-owner"}`) + case "/v1/registry/scrolls/revision": + if staged == "" || r.URL.Query().Get("artifact") != staged { + t.Error("did not freeze own staging tag") + } + json.NewEncoder(w).Encode(map[string]string{"artifact": "registry.test/team-owner/scroll-example@" + digest}) + case "/v1/registry/scrolls/releases/import": + imported++ + var input struct { + Repository, Tag, ReleaseArtifact string + ReviewedPublicContent bool + } + json.NewDecoder(r.Body).Decode(&input) + if input.Repository != name || input.Tag != "v1-commit" || input.ReleaseArtifact != "registry.test/team-owner/scroll-example@"+digest || input.ReviewedPublicContent != public { + t.Errorf("wrong import contract: %+v", input) + } + json.NewEncoder(w).Encode(map[string]string{"artifact": canonical + "@" + digest}) + case "/v1/registry/scrolls/publish": + published++ + var input struct { + Repository, Revision string + ReviewedPublicContent bool + } + json.NewDecoder(r.Body).Decode(&input) + if input.Repository != name || input.Revision != digest || !input.ReviewedPublicContent { + t.Error("publication lost exact revision/review") + } + fmt.Fprint(w, `{}`) + default: + t.Error("unexpected request") + w.WriteHeader(404) + } + })) + defer server.Close() + p := &publisher{base: server.URL, client: server.Client(), token: "fixture", owner: "team-owner", host: "registry.test", public: public, repositorySuffix: suffix} + command := []string{"druid", "push", "registry.test/team-owner/scroll-example:v1-commit", "./scroll"} + err := p.publish(command, func(args []string) error { + staged = args[2] + if !strings.HasPrefix(staged, "registry.test/team-owner/scroll-example:build-") { + t.Fatal("push not isolated in private staging") + } + return nil + }) + if err != nil { + t.Fatal(err) + } + if imported != 1 || (public && published != 1) || (!public && published != 0) { + t.Fatal("wrong lifecycle calls") + } + if command[2] != "registry.test/team-owner/scroll-example:v1-commit" { + t.Fatal("caller catalog mutated") + } + }) + } +} + +func TestWrongIdentityStopsBeforeRegistryPush(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"registry":"registry.test","project":"registry.test/other"}`) + })) + defer server.Close() + p := &publisher{base: server.URL, client: server.Client(), owner: "team-owner", host: "registry.test"} + called := false + err := p.publish([]string{"druid", "push", "registry.test/team-owner/example:v1", "./scroll"}, func([]string) error { called = true; return nil }) + if err == nil || called { + t.Fatal("wrong identity was allowed to push") + } +} + +func TestFailedPushNeverImports(t *testing.T) { + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + fmt.Fprint(w, `{"registry":"registry.test","project":"registry.test/team-owner"}`) + })) + defer server.Close() + p := &publisher{base: server.URL, client: server.Client(), owner: "team-owner", host: "registry.test"} + err := p.publish([]string{"druid", "push", "registry.test/team-owner/example:v1", "./scroll"}, func([]string) error { return errors.New("push failed") }) + if err == nil || calls != 1 { + t.Fatal("failed push reached lifecycle import") + } +} + +func TestConfigurationRejectsAdminCredentialsAndUnreviewedPublication(t *testing.T) { + t.Setenv("SCROLL_LIFECYCLE_URL", "https://api.druid.gg/core") + t.Setenv("SCROLL_LIFECYCLE_TOKEN", "fixture") + t.Setenv("SCROLL_LIFECYCLE_OWNER", "team-owner") + t.Setenv("SCROLL_REGISTRY_HOST", "registry.test") + t.Setenv("SCROLL_REGISTRY_NAMESPACE", "team-owner") + t.Setenv("SCROLL_REGISTRY_USER", "admin") + if _, err := configured(); err == nil { + t.Fatal("admin credentials accepted") + } + t.Setenv("SCROLL_REGISTRY_USER", "robot$team-owner+ci") + t.Setenv("SCROLL_LIFECYCLE_PUBLISH", "1") + t.Setenv("SCROLL_LIFECYCLE_REVIEWED", "0") + if _, err := configured(); err == nil { + t.Fatal("unreviewed public import accepted") + } + t.Setenv("SCROLL_LIFECYCLE_REVIEWED", "1") + if _, err := configured(); err != nil { + t.Fatal(err) + } +} + +func TestDedicatedAccountRefreshesJWTUsingInMemorySession(t *testing.T) { + tokens := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/auth/v2/sign-in/email": + var input map[string]string + json.NewDecoder(r.Body).Decode(&input) + if input["email"] != "team@example.test" || input["password"] != "fixture-password" { + t.Error("wrong dedicated sign-in credentials") + } + http.SetCookie(w, &http.Cookie{Name: "session", Value: "fixture-session", Path: "/"}) + fmt.Fprint(w, `{}`) + case "/auth/v2/token": + if cookie, err := r.Cookie("session"); err != nil || cookie.Value != "fixture-session" { + t.Error("session cookie missing") + } + tokens++ + fmt.Fprintf(w, `{"token":"refreshed-%d"}`, tokens) + case "/core/v1/registry/project": + if r.Header.Get("Authorization") != fmt.Sprintf("Bearer refreshed-%d", tokens) { + t.Error("fresh owner JWT missing") + } + fmt.Fprint(w, `{"registry":"registry.test","project":"registry.test/team-owner"}`) + default: + t.Error("unexpected request") + w.WriteHeader(404) + } + })) + defer server.Close() + t.Setenv("SCROLL_LIFECYCLE_URL", server.URL+"/core") + t.Setenv("SCROLL_AUTH_URL", server.URL+"/auth/v2") + t.Setenv("SCROLL_LIFECYCLE_TOKEN", "") + t.Setenv("SCROLL_TEAM_EMAIL", "team@example.test") + t.Setenv("SCROLL_TEAM_PASSWORD", "fixture-password") + t.Setenv("SCROLL_LIFECYCLE_OWNER", "team-owner") + t.Setenv("SCROLL_REGISTRY_NAMESPACE", "team-owner") + t.Setenv("SCROLL_REGISTRY_HOST", "registry.test") + t.Setenv("SCROLL_REGISTRY_USER", "robot$team-owner+ci") + t.Setenv("SCROLL_LIFECYCLE_PUBLISH", "0") + p, err := configured() + if err != nil { + t.Fatal(err) + } + if err := p.preflight(); err != nil { + t.Fatal(err) + } + if err := p.preflight(); err != nil { + t.Fatal(err) + } + if tokens != 2 { + t.Fatal("owner JWT was not refreshed before each lifecycle request") + } +} + +func TestPublisherNeverFollowsCredentialedRedirects(t *testing.T) { + forwarded := false + other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { forwarded = true })) + defer other.Close() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, other.URL, http.StatusTemporaryRedirect) + })) + defer server.Close() + t.Setenv("SCROLL_LIFECYCLE_URL", server.URL) + t.Setenv("SCROLL_LIFECYCLE_TOKEN", "fixture") + t.Setenv("SCROLL_LIFECYCLE_OWNER", "team-owner") + t.Setenv("SCROLL_REGISTRY_NAMESPACE", "team-owner") + t.Setenv("SCROLL_REGISTRY_HOST", "registry.test") + t.Setenv("SCROLL_REGISTRY_USER", "robot$team-owner+ci") + t.Setenv("SCROLL_LIFECYCLE_PUBLISH", "0") + p, err := configured() + if err != nil { + t.Fatal(err) + } + if err := p.preflight(); err == nil { + t.Fatal("redirect accepted") + } + if forwarded { + t.Fatal("credentials reached redirect target") + } +} diff --git a/scripts/publish-lifecycle/push_script_test.go b/scripts/publish-lifecycle/push_script_test.go new file mode 100644 index 00000000..e4cd4638 --- /dev/null +++ b/scripts/publish-lifecycle/push_script_test.go @@ -0,0 +1,174 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" +) + +// Exercise the real catalog/pool with an offline publisher boundary. Publisher +// HTTP/auth behavior is covered separately in main_test.go; no registry is used. +func TestPushScriptUsesLifecycleForEveryArtifact(t *testing.T) { + for _, jobs := range []string{"1", "3"} { + t.Run("jobs-"+jobs, func(t *testing.T) { + state, run := lifecycleScriptFixture(t, jobs) + if output, err := run(); err != nil { + t.Fatalf("push script: %v\n%s", err, output) + } + calls := readFixture(t, filepath.Join(state, "publisher.log")) + events := readFixture(t, filepath.Join(state, "events.log")) + starts, active, peak := 0, 0, 0 + for _, line := range strings.Split(events, "\n") { + if strings.HasPrefix(line, "start artifact ") { + starts++ + active++ + if active > peak { + peak = active + } + } + if strings.HasPrefix(line, "end artifact ") { + active-- + } + } + expected, categories := catalogCounts(t) + if starts == 0 || starts != expected || len(strings.Split(strings.TrimSpace(calls), "\n")) != starts { + t.Fatalf("not every catalog artifact used the lifecycle publisher: %d starts\n%s", starts, calls) + } + for _, line := range strings.Split(strings.TrimSpace(calls), "\n") { + fields := strings.Fields(line) + if len(fields) < 2 || fields[0] != "1700000000" || !strings.HasPrefix(fields[1], "registry.invalid/fixture-owner/") || !strings.HasSuffix(fields[1], "-commit") { + t.Fatalf("unstable timestamp or wrong staging reference: %s", line) + } + if strings.Contains(line, " -i ") && !strings.Contains(line, " -i registry.invalid/druid-team/druid:") { + t.Fatalf("runtime image was redirected into private staging: %s", line) + } + } + if strings.Count(events, "start category ") != categories || strings.Count(events, "end category ") != categories || + strings.LastIndex(events, "end category ") < 0 || strings.LastIndex(events, "end category ") > strings.Index(events, "start artifact ") { + t.Fatal("lifecycle artifacts started before category completion") + } + if readFixture(t, filepath.Join(state, "active")) != "0\n" { + t.Fatal("publisher children remain active") + } + limit, _ := strconv.Atoi(jobs) + if active != 0 || peak < 1 || peak > limit || (limit > 1 && peak < 2) { + t.Fatalf("lifecycle artifact concurrency = %d, configured = %d, active = %d", peak, limit, active) + } + }) + } +} + +func TestPushScriptFailsBeforeMutationWhenLifecyclePreflightFails(t *testing.T) { + state, run := lifecycleScriptFixture(t, "3", "FAKE_PREFLIGHT_FAIL=1") + if output, err := run(); err == nil { + t.Fatalf("failed preflight accepted: %s", output) + } + for _, name := range []string{"events.log", "publisher.log", "login"} { + if _, err := os.Stat(filepath.Join(state, name)); !os.IsNotExist(err) { + t.Fatalf("failed preflight reached %s: %v", name, err) + } + } +} + +func TestPushScriptReapsLifecycleFailures(t *testing.T) { + state, run := lifecycleScriptFixture(t, "3", "FAKE_DRUID_FAIL_REF=registry.invalid/fixture-owner/scroll-minecraft-spigot:1.17-commit") + if output, err := run(); err == nil { + t.Fatalf("failed lifecycle artifact accepted: %s", output) + } + events := readFixture(t, filepath.Join(state, "events.log")) + expected, _ := catalogCounts(t) + if strings.Count(events, "start artifact ") != expected { + t.Fatal("lifecycle failure abandoned unstarted catalog artifacts") + } + if strings.Count(events, "start artifact ") != strings.Count(events, "end artifact ") || readFixture(t, filepath.Join(state, "active")) != "0\n" { + t.Fatal("lifecycle failure abandoned active publisher jobs") + } + if !strings.Contains(events, "status=23") { + t.Fatal("fixture did not exercise the publisher failure") + } +} + +func lifecycleScriptFixture(t *testing.T, jobs string, extra ...string) (string, func() ([]byte, error)) { + t.Helper() + root, err := filepath.Abs("../..") + if err != nil { + t.Fatal(err) + } + state := t.TempDir() + bin := filepath.Join(state, "bin") + if err := os.Mkdir(bin, 0700); err != nil { + t.Fatal(err) + } + stubs := map[string]string{ + "go": `#!/usr/bin/env bash +set -euo pipefail +[[ "$1" == run && "$2" == ./scripts/publish-lifecycle ]] || exit 91 +if [[ "$3" == preflight ]]; then + [[ "${FAKE_PREFLIGHT_FAIL:-0}" != 1 ]] || exit 92 + touch "$FAKE_DRUID_STATE_DIR/preflight" + exit 0 +fi +[[ -f "$FAKE_DRUID_STATE_DIR/preflight" && "$3" == -- && "$4" == "$DRUID_BIN" && "$5" == push && "$6" != category ]] || exit 93 +printf '%s %s %s\n' "$SOURCE_DATE_EPOCH" "$6" "$*" >> "$FAKE_DRUID_STATE_DIR/publisher.log" +shift 4 +exec bash "$FAKE_DRUID_SOURCE" "$@" +`, + "druid": `#!/usr/bin/env bash +set -euo pipefail +[[ -f "$FAKE_DRUID_STATE_DIR/preflight" ]] || exit 94 +if [[ "$1" == login ]]; then + touch "$FAKE_DRUID_STATE_DIR/login" + exit 0 +fi +[[ "$1" == push && "$2" == category ]] || exit 95 +exec bash "$FAKE_DRUID_SOURCE" "$@" +`, + } + for name, script := range stubs { + if err := os.WriteFile(filepath.Join(bin, name), []byte(script), 0700); err != nil { + t.Fatal(err) + } + } + return state, func() ([]byte, error) { + cmd := exec.Command("bash", filepath.Join(root, "scripts/push.sh")) + cmd.Dir = root + cmd.Env = append(os.Environ(), "PATH="+bin+":"+os.Getenv("PATH"), "DRUID_BIN="+filepath.Join(bin, "druid"), + "FAKE_DRUID_SOURCE="+filepath.Join(root, "scripts/tests/fixtures/fake-druid.sh"), "FAKE_DRUID_STATE_DIR="+state, + "FAKE_DRUID_SLEEP=0.001", "FAKE_DRUID_FAIL_REF=", "FAKE_PREFLIGHT_FAIL=0", "SCROLL_PUBLISH_MODE=lifecycle", + "SCROLL_PUSH_DRY_RUN=0", "SCROLL_PUSH_UI=0", "SCROLL_PUSH_CATEGORIES=1", "SCROLL_PUSH_ARTIFACTS=1", + "SCROLL_PUSH_JOBS="+jobs, "SCROLL_REGISTRY_HOST=registry.invalid", "SCROLL_REGISTRY_NAMESPACE=fixture-owner", + "SCROLL_REGISTRY_RUNTIME_NAMESPACE=druid-team", "SCROLL_TAG_SUFFIX=-commit", "SOURCE_DATE_EPOCH=1700000000", + "SCROLL_REGISTRY_USER=fixture", "SCROLL_REGISTRY_PASSWORD=fixture", "DRUID_SCROLL_RUNTIME_IMAGE=", "DRUID_SCROLL_STEAMCMD_IMAGE=") + cmd.Env = append(cmd.Env, extra...) + return cmd.CombinedOutput() + } +} + +func readFixture(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(data) +} + +func catalogCounts(t *testing.T) (artifacts, categories int) { + t.Helper() + for _, line := range strings.Split(readFixture(t, "../push.sh"), "\n") { + if strings.HasPrefix(strings.TrimSpace(line), "run druid push ") { + if strings.Contains(line, "push category ") { + categories++ + } else { + artifacts++ + } + } + } + if artifacts == 0 || categories == 0 { + t.Fatal("fixture could not read the explicit catalog") + } + return +} diff --git a/scripts/push.sh b/scripts/push.sh index ef5a9e1f..d3690ea9 100755 --- a/scripts/push.sh +++ b/scripts/push.sh @@ -18,6 +18,11 @@ SCROLL_PUSH_CATEGORIES="${SCROLL_PUSH_CATEGORIES:-1}" SCROLL_PUSH_ARTIFACTS="${SCROLL_PUSH_ARTIFACTS:-1}" SCROLL_PUSH_JOBS="${SCROLL_PUSH_JOBS:-1}" SCROLL_PUSH_UI="${SCROLL_PUSH_UI:-1}" +SCROLL_PUBLISH_MODE="${SCROLL_PUBLISH_MODE:-direct}" +if [[ "$SCROLL_PUBLISH_MODE" != "direct" && "$SCROLL_PUBLISH_MODE" != "lifecycle" ]]; then + echo "SCROLL_PUBLISH_MODE must be direct or lifecycle" >&2 + exit 2 +fi if [[ ! "$SCROLL_PUSH_JOBS" =~ ^[1-9][0-9]*$ ]]; then echo "SCROLL_PUSH_JOBS must be a positive integer (got: $SCROLL_PUSH_JOBS)" >&2 @@ -122,17 +127,25 @@ run() { fi if ((SCROLL_PUSH_JOBS == 1)); then - "${command[@]}" + execute_push "${command[@]}" return fi - "${command[@]}" & + execute_push "${command[@]}" & push_pids+=("$!") if ((${#push_pids[@]} >= SCROLL_PUSH_JOBS)); then wait_for_oldest_push fi } +execute_push() { + if [[ "$SCROLL_PUBLISH_MODE" == "lifecycle" && "${2:-}" == "push" && "${3:-}" != "category" ]]; then + go run ./scripts/publish-lifecycle -- "$@" + else + "$@" + fi +} + push_release_categories() { run druid push category artifacts.druid.gg/druid-team/scroll-minecraft-spigot minecraft ./scrolls/minecraft/minecraft-spigot/.meta run druid push category artifacts.druid.gg/druid-team/scroll-minecraft-vanilla minecraft ./scrolls/minecraft/minecraft-vanilla/.meta @@ -284,6 +297,11 @@ push_release_artifacts() { run druid push artifacts.druid.gg/druid-team/scroll-hytale:latest ./scrolls/hytale/hytale-druid-gg -p main=5520/udp -i artifacts.druid.gg/druid-team/druid:v0.1.257 --min-disk 10Gi --min-ram 4Gi --min-cpu 1 -m --smart --category hytale } +if [[ "$SCROLL_PUBLISH_MODE" == "lifecycle" && "$SCROLL_PUSH_DRY_RUN" != "1" ]]; then + # Freeze build metadata across retries of one reviewed commit. + export SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-$(git show -s --format=%ct HEAD)}" + go run ./scripts/publish-lifecycle preflight +fi login_if_configured if [[ "$SCROLL_PUSH_CATEGORIES" = "1" ]]; then diff --git a/scripts/stage-scroll-ui/main.go b/scripts/stage-scroll-ui/main.go index d59b6d58..8fff78c5 100644 --- a/scripts/stage-scroll-ui/main.go +++ b/scripts/stage-scroll-ui/main.go @@ -339,6 +339,22 @@ func stage(source, destination, bundle string) error { if err := cp.Copy(source, destination); err != nil { return fmt.Errorf("copy Scroll: %w", err) } + // Version directories inherit family presentation. Keep it inside the staged + // release so the lifecycle never depends on mutable category tags afterward. + if _, err := os.Stat(filepath.Join(destination, ".meta")); os.IsNotExist(err) { + for parent := filepath.Dir(source); parent != "." && parent != filepath.Dir(parent); parent = filepath.Dir(parent) { + metadata := filepath.Join(parent, ".meta") + if info, err := os.Stat(metadata); err == nil && info.IsDir() { + if err := cp.Copy(metadata, filepath.Join(destination, ".meta")); err != nil { + return fmt.Errorf("copy presentation: %w", err) + } + break + } + if filepath.Base(parent) == "scrolls" { + break + } + } + } scrollPath := filepath.Join(destination, "scroll.yaml") scrollBytes, err := os.ReadFile(scrollPath) diff --git a/scripts/stage-scroll-ui/main_test.go b/scripts/stage-scroll-ui/main_test.go index 90704054..dd0a02f3 100644 --- a/scripts/stage-scroll-ui/main_test.go +++ b/scripts/stage-scroll-ui/main_test.go @@ -16,6 +16,13 @@ func TestStageAddsPrivateUIAndMinecraftManifest(t *testing.T) { if err := os.MkdirAll(source, 0755); err != nil { t.Fatal(err) } + metadata := filepath.Join(filepath.Dir(source), ".meta") + if err := os.MkdirAll(metadata, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(metadata, "en-US.md"), []byte("---\nname: Paper\n---\nPresentation\n"), 0644); err != nil { + t.Fatal(err) + } if err := os.WriteFile(filepath.Join(source, "scroll.yaml"), []byte("name: test\ndesc: test\nversion: 0.0.1\napp_version: 1.21.7\ncommands: {}\n"), 0644); err != nil { t.Fatal(err) } @@ -40,6 +47,9 @@ func TestStageAddsPrivateUIAndMinecraftManifest(t *testing.T) { if err := stage(source, destination, bundle); err != nil { t.Fatal(err) } + if _, err := os.Stat(filepath.Join(destination, ".meta", "en-US.md")); err != nil { + t.Fatal("family presentation missing from release", err) + } stagedYAML, err := os.ReadFile(filepath.Join(destination, "scroll.yaml")) if err != nil { diff --git a/scripts/validate-release-workflow/lifecycle_workflow.go b/scripts/validate-release-workflow/lifecycle_workflow.go new file mode 100644 index 00000000..b5bb5ae3 --- /dev/null +++ b/scripts/validate-release-workflow/lifecycle_workflow.go @@ -0,0 +1,124 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "gopkg.in/yaml.v3" +) + +type lifecycleWorkflow struct { + Jobs map[string]lifecycleJob `yaml:"jobs"` +} + +type lifecycleJob struct { + Uses string `yaml:"uses"` + With map[string]any `yaml:"with"` + Secrets any `yaml:"secrets"` + Env map[string]string `yaml:"env"` + Needs string `yaml:"needs"` + If string `yaml:"if"` + RunsOn string `yaml:"runs-on"` + Environment string `yaml:"environment"` + Concurrency struct { + Cancel bool `yaml:"cancel-in-progress"` + } `yaml:"concurrency"` + Steps []lifecycleStep `yaml:"steps"` +} + +type lifecycleStep struct { + Name string `yaml:"name"` + Uses string `yaml:"uses"` + Run string `yaml:"run"` + Env map[string]string `yaml:"env"` + With map[string]any `yaml:"with"` +} + +// Check the actual CI callers, not only the opt-in helper. Missing credentials +// must never silently select the legacy direct-publication path. +func validateLifecycleWorkflows(directory string) error { + read := func(name string) (lifecycleWorkflow, error) { + var workflow lifecycleWorkflow + data, err := os.ReadFile(filepath.Join(directory, name)) + if err == nil { + err = yaml.Unmarshal(data, &workflow) + } + return workflow, err + } + for file, public := range map[string]bool{"release.yml": true, "pr.yml": false} { + workflow, err := read(file) + if err != nil { + return err + } + job := workflow.Jobs["build-deploy"] + if len(workflow.Jobs) != 1 || job.Uses != "./.github/workflows/scroll-lifecycle.yml" || job.With["public"] != public || job.Secrets != nil { + return fmt.Errorf("%s must call the shared lifecycle with public=%t and no inherited secrets", file, public) + } + } + workflow, err := read("scroll-lifecycle.yml") + if err != nil { + return err + } + verify, publish := workflow.Jobs["verify"], workflow.Jobs["publish"] + if publish.Needs != "verify" || publish.Environment != "${{ inputs.public && 'scroll-release' || 'scroll-preview' }}" || publish.RunsOn != "ubuntu-latest" || publish.Concurrency.Cancel { + return fmt.Errorf("publication requires verification, protected environments, a fresh runner and non-cancelling concurrency") + } + wantCondition := "needs.verify.outputs.changed == 'true' && ((inputs.public && github.event_name != 'pull_request') || (!inputs.public && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository))" + if strings.Join(strings.Fields(publish.If), " ") != wantCondition { + return fmt.Errorf("publication must reject fork PRs and public PR publication") + } + if len(verify.Env) != 0 || len(publish.Env) != 0 { + return fmt.Errorf("lifecycle credentials/configuration must be scoped to steps, not whole jobs") + } + tests, gate, cli, publishCount := false, false, false, 0 + for _, step := range verify.Steps { + if strings.Contains(step.Run, "go test ./scripts/publish-lifecycle ./scripts/stage-scroll-ui ./scripts/validate-release-workflow") { + tests = true + } + for _, value := range step.Env { + if strings.Contains(value, "secrets.") { + return fmt.Errorf("validation must not receive publication secrets") + } + } + } + for index, step := range publish.Steps { + if step.Name == "Require provisioned lifecycle environment" && step.Env["LIFECYCLE_READY"] == "${{ vars.SCROLL_LIFECYCLE_READY }}" && strings.Contains(step.Run, "exit 1") { + if index != 0 { + return fmt.Errorf("provisioning gate must precede every publication job step") + } + gate = true + } + if step.With["repository"] == "highcard-dev/druid-cli" { + ref, _ := step.With["ref"].(string) + cli = regexp.MustCompile(`^[a-f0-9]{40}$`).MatchString(ref) && step.With["persist-credentials"] == false + } + if step.Run != "bash ./scripts/push.sh" { + continue + } + publishCount++ + for name, want := range map[string]string{ + "SCROLL_PUBLISH_MODE": "lifecycle", + "SCROLL_REGISTRY_NAMESPACE": "${{ vars.SCROLL_LIFECYCLE_OWNER }}", + "SCROLL_REGISTRY_RUNTIME_NAMESPACE": "druid-team", + "SCROLL_REGISTRY_USER": "${{ secrets.SCROLL_TEAM_REGISTRY_USER }}", + "SCROLL_REGISTRY_PASSWORD": "${{ secrets.SCROLL_TEAM_REGISTRY_PASSWORD }}", + "SCROLL_TEAM_EMAIL": "${{ secrets.SCROLL_TEAM_EMAIL }}", + "SCROLL_TEAM_PASSWORD": "${{ secrets.SCROLL_TEAM_PASSWORD }}", + "SCROLL_TAG_SUFFIX": "-${{ github.event.pull_request.head.sha || github.sha }}", + "SCROLL_LIFECYCLE_REPOSITORY_SUFFIX": "${{ github.event_name == 'pull_request' && format('-pr{0}', github.event.pull_request.number) || '' }}", + "SCROLL_LIFECYCLE_PUBLISH": "${{ inputs.public && '1' || '0' }}", + "SCROLL_LIFECYCLE_REVIEWED": "${{ inputs.public && '1' || '0' }}", + } { + if step.Env[name] != want { + return fmt.Errorf("lifecycle publication has incorrect %s", name) + } + } + } + if !tests || !gate || !cli || publishCount != 1 { + return fmt.Errorf("shared workflow needs publisher tests, provisioning gate, pinned CLI and one lifecycle catalog call") + } + return nil +} diff --git a/scripts/validate-release-workflow/lifecycle_workflow_test.go b/scripts/validate-release-workflow/lifecycle_workflow_test.go new file mode 100644 index 00000000..1505a330 --- /dev/null +++ b/scripts/validate-release-workflow/lifecycle_workflow_test.go @@ -0,0 +1,95 @@ +package main + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "gopkg.in/yaml.v3" +) + +func TestLifecycleWorkflows(t *testing.T) { + if err := validateLifecycleWorkflows("../../.github/workflows"); err != nil { + t.Fatal(err) + } +} + +func TestLifecycleProvisioningGate(t *testing.T) { + data, err := os.ReadFile("../../.github/workflows/scroll-lifecycle.yml") + if err != nil { + t.Fatal(err) + } + var workflow lifecycleWorkflow + if err := yaml.Unmarshal(data, &workflow); err != nil { + t.Fatal(err) + } + steps := workflow.Jobs["publish"].Steps + if len(steps) == 0 || steps[0].Name != "Require provisioned lifecycle environment" { + t.Fatal("provisioning gate must run before checkout or any credential-bearing step") + } + for _, value := range []string{"", "false", "TRUE", "true"} { + t.Run("ready="+value, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + command := exec.CommandContext(ctx, "bash", "-eo", "pipefail", "-c", steps[0].Run) + command.Env = []string{"PATH=" + os.Getenv("PATH")} + if value != "" { + command.Env = append(command.Env, "LIFECYCLE_READY="+value) + } + output, err := command.CombinedOutput() + if ctx.Err() != nil { + t.Fatal(ctx.Err()) + } + if value == "true" { + if err != nil { + t.Fatalf("provisioned environment rejected: %v: %s", err, output) + } + } else if exit, ok := err.(*exec.ExitError); !ok || exit.ExitCode() != 1 { + t.Fatalf("unprovisioned environment must exit 1: %v: %s", err, output) + } + }) + } +} + +func TestLifecycleWorkflowRejectsMissingCutoverGuards(t *testing.T) { + for name, change := range map[string][3]string{ + "legacy caller": {"release.yml", "./.github/workflows/scroll-lifecycle.yml", "./.github/workflows/legacy.yml"}, + "public preview": {"pr.yml", "public: false", "public: true"}, + "direct fallback": {"scroll-lifecycle.yml", "SCROLL_PUBLISH_MODE: lifecycle", "SCROLL_PUBLISH_MODE: direct"}, + "mutable revision": {"scroll-lifecycle.yml", "SCROLL_TAG_SUFFIX: -${{ github.event.pull_request.head.sha || github.sha }}", "SCROLL_TAG_SUFFIX: -latest"}, + "public preview repository": {"scroll-lifecycle.yml", "format('-pr{0}', github.event.pull_request.number) || ''", "''"}, + "admin credential fallback": {"scroll-lifecycle.yml", "secrets.SCROLL_TEAM_REGISTRY_USER", "secrets.SCROLL_REGISTRY_USER"}, + "fork publication": {"scroll-lifecycle.yml", "github.event.pull_request.head.repo.full_name == github.repository", "true"}, + "no review environment": {"scroll-lifecycle.yml", "environment: ${{ inputs.public && 'scroll-release' || 'scroll-preview' }}", "environment: production"}, + "mutable CLI": {"scroll-lifecycle.yml", "a9f5dd9ec361ee5b268dfa5b5c2a56ef60f350fd", "master"}, + "cancel mutation": {"scroll-lifecycle.yml", "cancel-in-progress: false", "cancel-in-progress: true"}, + "skip publisher tests": {"scroll-lifecycle.yml", "go test ./scripts/publish-lifecycle", "go test ./scripts/prebuild"}, + "late provisioning gate": {"scroll-lifecycle.yml", " - name: Require provisioned lifecycle environment", " - run: echo premature-step\n - name: Require provisioned lifecycle environment"}, + } { + t.Run(name, func(t *testing.T) { + dir := t.TempDir() + for _, file := range []string{"release.yml", "pr.yml", "scroll-lifecycle.yml"} { + data, err := os.ReadFile(filepath.Join("../../.github/workflows", file)) + if err != nil { + t.Fatal(err) + } + if file == change[0] { + if !strings.Contains(string(data), change[1]) { + t.Fatal("mutation did not match workflow") + } + data = []byte(strings.Replace(string(data), change[1], change[2], 1)) + } + if err := os.WriteFile(filepath.Join(dir, file), data, 0600); err != nil { + t.Fatal(err) + } + } + if err := validateLifecycleWorkflows(dir); err == nil { + t.Fatal("unsafe workflow contract accepted") + } + }) + } +} diff --git a/scripts/validate-release-workflow/main.go b/scripts/validate-release-workflow/main.go index d0796d05..92c5fd0a 100644 --- a/scripts/validate-release-workflow/main.go +++ b/scripts/validate-release-workflow/main.go @@ -33,6 +33,10 @@ func main() { fmt.Fprintln(os.Stderr, "Error:", err) os.Exit(1) } + if err := validateLifecycleWorkflows(".github/workflows"); err != nil { + fmt.Fprintln(os.Stderr, "Error:", err) + os.Exit(1) + } } func validateReleaseWorkflow(path string) error {