From c22dd38cc0ed62d9bc4349cf621a14264d3bb9cb Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Fri, 4 Sep 2026 23:06:02 +0200 Subject: [PATCH 01/13] Define workflow trigger and cleanup guarantees --- .../specification/workflow-triggers/spec.md | 215 ++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 docs/content/specification/workflow-triggers/spec.md diff --git a/docs/content/specification/workflow-triggers/spec.md b/docs/content/specification/workflow-triggers/spec.md new file mode 100644 index 00000000..88279e06 --- /dev/null +++ b/docs/content/specification/workflow-triggers/spec.md @@ -0,0 +1,215 @@ +--- +title: Workflow triggers - Spec +description: Preserve production runs, replace obsolete pull-request work, and clean up owned prereleases after closure. +--- + +# Workflow triggers - Spec + +Process-PSModule admits work into independent production, pull-request, and closure tracks. Production runs retain accepted work; pull-request runs favor the latest revision; closure stops obsolete work before optionally removing its prereleases. Module repositories select events and provide credentials, not scheduling algorithms. + +## Problem + +One scheduling policy cannot safely serve both production publication and rapid pull-request updates. Replacing queued production work loses delivery intent; retaining obsolete pull-request work delays feedback. Cleanup that races a publisher can leave orphaned prereleases or remove another pull request's output. + +## Outcomes and impact + +- **Outcome:** Accepted production events reach a terminal result without replacement by later events, while obsolete pull-request runs yield to current work. +- **DORA:** Shorter feedback lead time without increasing publication failures caused by overlapping runs. +- **Domain signal:** Zero production events evicted within supported queue capacity, zero overlapping production pipelines, and zero owned GitHub prereleases remaining after successful enabled cleanup. + +## Users and jobs + +Module maintainers publish accepted changes reliably. Contributors receive feedback for the latest pull-request state. Repository operators choose whether to retain prereleases and can identify failed, canceled, or capacity-rejected work. + +## Scope + +**In scope:** Event admission, track isolation, production serialization, pull-request supersession, closure cancellation, prerelease cleanup, and the minimum caller contract. + +**Out of scope:** Version-label policy, quality gates, module build logic, merge-queue integration, and changing the production branch model. + +## Non-goals + +- Unlimited durable event storage or guaranteed completion despite platform outages, failures, manual cancellation, or timeouts. +- Debouncing that prevents every obsolete run from starting. +- Deleting or unlisting PowerShell Gallery packages, or treating cancellation as rollback of an external publication. + +## Functional requirements + +### FR1 - Central scheduling policy {#fr1} + +The framework MUST own track selection, queue policy, and cancellation rules. A caller MUST NOT need concurrency expressions, a dispatcher job, or separate jobs for each track. Callers MUST subscribe to the required events and supply the established credentials and permissions. Cleanup configuration MUST use the existing repository settings contract. + +#### Caller scenario + +```gherkin +Scenario: Use the standard caller + Given a caller with the required event subscriptions, permissions, and secrets + And no caller concurrency block or scheduling inputs + When supported events invoke the framework + Then the framework selects their tracks without additional caller logic +``` + +### FR2 - Retain and serialize production work {#fr2} + +Each push to the repository default branch, normally `main`, MUST enter the production track. All supported stable-publication entry points MUST share its serialization boundary. At most one production pipeline MUST execute at a time, from planning and version resolution through its final enabled stage. + +A later event MUST NOT cancel a running production pipeline or replace an accepted pending production event within supported capacity. Pending events MUST execute sequentially in admission order, subject to the platform ordering boundary in the design. Each run MUST retain its triggering revision; it MUST NOT silently build a newer branch tip after waiting. Admission does not bypass important-file, release-label, or quality gates. + +#### Production scenarios + +```gherkin +Scenario: Preserve a production burst + Given production run A is executing + When pushes B and C are admitted in that order + Then A is not canceled by either push + And B and C remain pending + And B executes after A terminates + And C executes after B terminates + And each run uses its own triggering revision + +Scenario: Share the production authority + Given a production push run is executing + When a supported manual stable-publication run is admitted + Then the manual run waits in the same production track +``` + +### FR3 - Supersede obsolete pull-request work {#fr3} + +An update to an open pull request MUST request cancellation of older running work and replace older pending work for that pull request. Different pull requests MUST NOT cancel one another. Once updates stop and cancellation settles, only the latest eligible state proceeds; obsolete work MUST NOT begin another publication after failing a freshness check. + +The rule applies to revision updates and supported release-intent changes, including label removal. Cancellation MAY allow already-started steps or external requests to finish. A burst therefore guarantees latest-state convergence, not exactly one workflow start. + +#### Supersession scenarios + +```gherkin +Scenario: Push three revisions rapidly + Given revision A is running for pull request 42 + When revisions B and C arrive in that order before A finishes + And no later updates arrive + Then cancellation is requested for superseded work + And only C proceeds after cancellation settles + And production runs and pull request 43 are unaffected + +Scenario: Remove prerelease intent + Given a pull-request run is eligible to publish a prerelease + When the prerelease label is removed before publication begins + Then the older run is superseded + And its publication eligibility is rechecked before any new publication +``` + +### FR4 - Close, stop, then clean {#fr4} + +Both merged and abandoned pull requests MUST enter the closure track. Closure MUST request cancellation of running and pending activity belonging to the closing pull-request lifecycle, then wait for that activity to reach a terminal state before deleting prereleases. Closure MUST NOT build, test, publish a module, deploy a site, or authorize a stable release. + +Closure runs MUST NOT be canceled by production pushes, pull-request updates, or later closure events. A merge's closure and default-branch push MUST remain separate operations. Closing obsolete activity is independent of whether prerelease deletion is enabled. + +#### Closure scenarios + +```gherkin +Scenario: Close during prerelease publication + Given a pull-request activity run is publishing a prerelease + When that pull request closes + Then the closure run requests cancellation of that activity + And it waits for the activity to terminate before deleting prereleases + And it reports incomplete cleanup if termination cannot be confirmed + +Scenario: Merge creates two independent operations + Given a prerelease pull request is merged + When its closure event and default-branch push are admitted + Then closure stops obsolete pull-request work and evaluates cleanup + And the push enters the production queue + And neither operation cancels the other +``` + +### FR5 - Optional, exact, repeatable cleanup {#fr5} + +Automatic cleanup MUST be enabled by default and MUST be disableable through the established cleanup setting. Enabled cleanup MUST enumerate all GitHub prerelease releases and tags provably owned by the closing pull-request lifecycle, including recoverable partial publication records. It MUST preserve stable releases, other pull requests' resources, and resources from a later reopening. + +Cleanup MUST work without the prerelease label still being present, without important-file changes, and without the source branch still existing. Repeating successful cleanup MUST succeed with nothing left to delete. Ambiguous ownership, incomplete enumeration, or an unresolved deletion MUST be reported, not presented as successful complete cleanup. + +#### Cleanup scenarios + +```gherkin +Scenario: Retain prereleases by configuration + Given automatic cleanup is disabled + When a pull request closes + Then obsolete activity is stopped + And no prerelease release or tag is deleted + And retention is reported as intentional + +Scenario: Clean beyond the first page + Given a closed pull request owns prereleases spanning multiple result pages + And its prerelease label and source branch have been removed + When enabled cleanup succeeds + Then all its owned GitHub prerelease releases and tags are absent + And stable releases and other pull requests' resources are unchanged + +Scenario: Reopen while cleanup is pending + Given a closure run is pending for an earlier pull-request lifecycle + When the pull request reopens and produces another prerelease + Then the earlier closure neither cancels the reopened activity nor deletes its output +``` + +## Non-functional requirements + +### NFR1 - Maximum supported queue capacity {#nfr1} + +The production track MUST use the platform's maximum supported pending-run capacity, with no smaller framework or caller limit. Capacity and ordering guarantees MUST be documented precisely. Overflow or expiry MUST remain visible as unsuccessful admission or execution; it MUST NOT be described as successful delivery. + +#### Capacity scenario + +```gherkin +Scenario: Fill the production queue + Given one production run is active + When pending runs fill the documented maximum capacity + Then every admitted pending run is retained + When another run exceeds capacity + Then the platform's rejection or cancellation is visible + And no retained run is silently replaced by the framework +``` + +### NFR2 - Bounded and diagnosable cleanup {#nfr2} + +Every executing closure run MUST record its pull request, lifecycle boundary, cleanup decision, targeted run IDs, and resource outcomes without exposing secrets. Waiting for canceled activity MUST have a finite documented deadline. Deadline expiry MUST fail cleanup without beginning deletion; enumeration and deletion failures MUST produce a failing result with enough identity to retry safely. + +### NFR3 - Narrow authority {#nfr3} + +Cancellation and deletion MUST be restricted to the caller repository and verified framework-owned work. Cleanup MUST NOT execute untrusted pull-request code with write credentials. Unsupported credential contexts MUST fail or explicitly skip privileged work; they MUST NOT appear to have cleaned resources. + +## Acceptance criteria + +```gherkin +# AC1 - Verifies: FR2, FR3, FR4, FR5, NFR2 +Scenario: Production burst during pull-request closure + Given one production run and one prerelease pull-request run are active + When two more production pushes arrive and the pull request closes + Then all three production runs remain serialized and are not superseded + And the closing pull-request activity is canceled before cleanup deletes anything + And successful enabled cleanup leaves no owned GitHub prereleases + And the closure run records what was stopped and removed + +# AC2 - Verifies: FR1, FR4, FR5, NFR3 +Scenario: Clean an abandoned change with the standard caller + Given the standard caller and default cleanup settings + And an abandoned pull request owns several prereleases + When it closes without merging + Then cleanup runs without a special caller job + And no stable release is created + And only resources with verified ownership are deleted +``` + +## Constraints and assumptions + +- **Constraint:** Platform scheduling and cancellation are asynchronous. Cancellation does not undo PowerShell Gallery uploads or already-accepted API requests. +- **Constraint:** The caller controls event delivery, filtering, permissions, and any outer concurrency policy. The framework cannot recover events the caller never invokes. +- **Constraint:** Guarantees apply within documented platform queue and runtime limits, not to arbitrary external cancellation or failed quality gates. +- **Assumption:** A repository has one production caller for a module. Independent modules or unrelated workflows are not coordinated unless they share an explicit framework identity. + +## Dependencies + +- [Framework spec](../spec.md) - release authority and publication requirements. +- [Settings](../../reference/settings.md) - authorable cleanup policy. + +## Where this connects + +- [Design](design.md) - scheduling boundaries, platform limits, and cleanup implementation. From 0e737e8e6ad8b8709c644ddb09d81edfc54939bc Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Fri, 4 Sep 2026 23:15:33 +0200 Subject: [PATCH 02/13] Design framework-owned trigger admission and closure coordination --- docs/content/specification/design.md | 8 + docs/content/specification/index.md | 1 + docs/content/specification/spec.md | 5 +- .../specification/workflow-triggers/design.md | 247 ++++++++++++++++++ .../specification/workflow-triggers/index.md | 15 ++ .../specification/workflow-triggers/spec.md | 3 + docs/zensical.toml | 5 + 7 files changed, 282 insertions(+), 2 deletions(-) create mode 100644 docs/content/specification/workflow-triggers/design.md create mode 100644 docs/content/specification/workflow-triggers/index.md diff --git a/docs/content/specification/design.md b/docs/content/specification/design.md index 521b71b7..c9dbbe8d 100644 --- a/docs/content/specification/design.md +++ b/docs/content/specification/design.md @@ -17,6 +17,13 @@ passes the required secrets. The full caller template is in [Repository setup](../get-started/repository-setup.md#3-add-the-caller-workflow), and the interface it targets is documented in [Workflow inputs](../reference/workflow-inputs.md). +### Trigger admission + +The [workflow trigger design](workflow-triggers/design.md) owns admission before processing: a retained production queue, +replaceable pull-request activity, and protected closure coordination. Concurrency belongs to the reusable entry point, +not to caller templates or individual processing stages. The complete processing call retains its admission slot through +the final enabled stage; closure stops obsolete activity before optional prerelease cleanup. + ### Composed reusable workflows The main workflow composes work across specialized reusable workflows, each owning a pipeline stage: @@ -110,6 +117,7 @@ independently; the main workflow pins versions explicitly. The full list is in ## Where this connects - [Spec](spec.md) — the requirements this design delivers. +- [Workflow triggers](workflow-triggers/index.md) — scheduling requirements and the event-routing design. - [Pipeline stages](../reference/pipeline-stages.md) — detailed breakdown of each job. - [Calling the workflow](../guides/calling-the-workflow.md) — how to invoke it. - [Settings](../reference/settings.md) — the settings file reference. diff --git a/docs/content/specification/index.md b/docs/content/specification/index.md index 0b90c3a1..75d9a601 100644 --- a/docs/content/specification/index.md +++ b/docs/content/specification/index.md @@ -13,4 +13,5 @@ delivered. They are aimed at people maintaining Process-PSModule itself. Module | --- | --- | | [Spec](spec.md) | Requirements — an end-to-end pipeline guaranteeing build, testing, quality gates, documentation, and versioned publication. | | [Design](design.md) | How the spec is delivered — a single reusable workflow composing sub-workflows, and the settings contract. | +| [Workflow triggers](workflow-triggers/index.md) | Production queuing, pull-request supersession, and optional closure cleanup, with a dedicated spec and design. | | [Principles and practices](principles-and-practices.md) | The versioning, branching, and colocation principles behind the design. | diff --git a/docs/content/specification/spec.md b/docs/content/specification/spec.md index 5755abf2..ed0a6c57 100644 --- a/docs/content/specification/spec.md +++ b/docs/content/specification/spec.md @@ -61,7 +61,7 @@ Versions MUST follow [SemVer 2.0.0](https://semver.org/) (`vMAJOR.MINOR.PATCH` o ### NFR2 — Serialized releases {#nfr2} -Only one release process MUST run against a given version of the codebase at a time. Concurrent releases to the same ref MUST be prevented, so the tag, version counter, and published artifact remain consistent. +Production pipelines MUST execute serially from planning through their final enabled stage, retaining pending work within the platform's maximum queue capacity. Pull-request updates MUST supersede obsolete activity without canceling production or closure cleanup. The [workflow trigger spec](workflow-triggers/spec.md) owns admission, ordering boundaries, and cleanup guarantees. ### NFR3 — Single production authority {#nfr3} @@ -73,7 +73,7 @@ Pipeline failures MUST be visible in the pull request and block merge. Contribut ### NFR5 — Reproducible and auditable {#nfr5} -The entire pipeline and its decisions MUST be stored in git, so the build is reproducible and auditable from the commit alone. No external configuration, API calls, or out-of-band decisions. +Pipeline policy and configuration MUST be version-controlled. Each run MUST record its triggering revision and the authenticated event, release, and configuration metadata used for its decisions, so queuing or later metadata changes do not erase the decision trail. ## Success Criteria @@ -151,6 +151,7 @@ Scenario: Handle documentation generation failure ## Where this connects - [Design](design.md) — how these requirements are delivered. +- [Workflow triggers](workflow-triggers/index.md) — production, pull-request, and closure scheduling. - [Pipeline stages](../reference/pipeline-stages.md) — the job-by-job breakdown of the workflow. - [Calling the workflow](../guides/calling-the-workflow.md) — how to invoke the workflow. - [Settings](../reference/settings.md) — the settings file and its options. diff --git a/docs/content/specification/workflow-triggers/design.md b/docs/content/specification/workflow-triggers/design.md new file mode 100644 index 00000000..e765cd93 --- /dev/null +++ b/docs/content/specification/workflow-triggers/design.md @@ -0,0 +1,247 @@ +--- +title: Workflow triggers - Design +description: Framework-owned native admission with retained production work, replaceable PR activity, and cancellation-aware cleanup. +--- + +# Workflow triggers - Design + +The reusable entry point, `.github/workflows/workflow.yml`, routes events into concurrency-controlled calls to processing or cleanup workflows. Native admission owns queuing; the close path reconciles cancellation and resource ownership before deletion. No external queue service or caller-side dispatcher is required. + +## Specification + +[Workflow triggers - Spec](spec.md) defines the behavior. The [framework design](../design.md) owns the processing stages and settings contract. + +## Approach + +Use three mutually exclusive entry jobs with literal concurrency policies. The production and activity jobs each call the complete processing workflow, retaining their concurrency slot until its nested jobs finish. The close job calls only closure coordination and cleanup. A small framework-owned preflight records invocation identity before these jobs become eligible; it performs no planning, version resolution, or module execution. + +This avoids an expression-valued `queue`, whose official parser support is stronger than its documented examples, and avoids a short-lived admission job that releases its slot before the pipeline starts. `Plan` and version resolution execute inside the processing call, after admission. + +### Platform contract + +| Concern | Contract | +| --- | --- | +| Retained queue | `queue: max` permits **100 pending executions plus one running execution** per group. | +| Overflow | Additional executions are canceled when the pending queue is full; the limit cannot be increased by support. | +| Replacement queue | `queue: single` is the default. A newcomer replaces an existing pending execution even when `cancel-in-progress` is `false`. | +| Ordering | FIFO by the time work starts waiting for the concurrency group, **not** by push, commit, or workflow-dispatch time. | +| Valid combinations | `queue: max` requires `cancel-in-progress: false` or omission. Combining it with `true` is invalid. | +| Group scope | Repository-local and case-insensitive. Caller and called workflow must not acquire the same group. | +| Other limits | The workflow lifetime limit includes waiting; event-trigger rate limits also apply. A 100-entry group is not an unlimited durable queue. | + +These are [GitHub's concurrency guarantees][concurrency] and [Actions limits][limits]. The native queue meets the accepted bounded-retention contract, not strict chronological push ordering. + +### Alternatives considered + +| Option | Trade-offs | Verdict | +| --- | --- | --- | +| Internal router with literal per-track policies | Adds one reusable-workflow layer; keeps caller configuration small and protects the complete pipeline. | Chosen. | +| Conditional workflow-level concurrency | Fewer internal jobs, but conditional `queue` needs live-service evidence beyond parser support. | Not required for this design. | +| Caller-owned concurrency | Can protect the entire caller, but duplicates policy and can discard work before the framework receives it. | Rejected for the standard caller. | +| One cancelable group for activity and closure | Close can supersede activity natively, but reopening or another update can cancel cleanup. | Rejected. | +| Concurrency only on publication or on a short admission job | Allows planning/version races or releases the lock before processing ends. | Rejected. | +| Durable external dispatcher | Can provide stronger ordering and retention but adds persistent state and operational ownership. | Outside the bounded native-queue contract. | + +## Architecture + +```mermaid +flowchart TD + Caller["Caller: events, permissions, secrets"] --> Receipt["Record invocation identity"] + Receipt --> Router["workflow.yml: event routing"] + Router --> Production["Production admission: max queue, no cancellation"] + Router --> Activity["PR activity admission: single queue, cancel older work"] + Router --> Close["Close admission: max queue, no cancellation"] + Production --> Process["Processing workflow: Plan through final enabled stage"] + Activity --> Process + Close --> Stop["Cancel matching PR activity and await termination"] + Stop --> Policy["Load trusted cleanup settings"] + Policy --> Cleanup["Reconcile and delete exactly owned prereleases"] + Policy --> Retain["Report intentional retention"] +``` + +### Event routing + +Routing uses the original `github` event context. The called workflow sees the caller's context; it does not receive `workflow_call` as a replacement business event. Routing does not require the settings file. + +| Event | Track | Execution | +| --- | --- | --- | +| `push` to `github.event.repository.default_branch` | Production | Full processing; existing change detection and release gates still apply. | +| `workflow_dispatch` on the default branch | Production | Same serialization and stable-release policy as the supported manual path. | +| `pull_request`: `opened`, `reopened`, `synchronize`, `labeled`, `unlabeled` | PR activity | Replace obsolete work; validate live PR state before processing and publication. | +| `pull_request`: `closed`, whether merged or abandoned | Close | Stop activity, then evaluate optional cleanup; never enter the processing DAG. | +| Optional `schedule`, or manual validation on another branch | Validation | Nonpublishing processing, isolated from production and closure; latest-per-ref admission. | +| Other events or actions | Unsupported | Explicitly report no supported route; never fall through into publication. | + +Default-branch identity comes from the event's repository metadata rather than a hard-coded `main`. Caller branch filters still name the repository's actual default branch. A merged close event can have the default-branch `github.ref`, so PR identity always uses the PR number, not that ref. + +### Admission boundary and keys + +The framework namespace is `Process-PSModule-${{ github.workflow }}`. One stable-named caller owns a module's production path; different caller names are not a substitute for coordinating two publishers of the same module. + +| Track | Group suffix | Queue | Cancel in progress | +| --- | --- | --- | --- | +| Production | `-production` | `max` | `false` | +| PR activity | `-pr-` | `single` | `true` | +| Close | `-close-pr-` | `max` | `false` | +| Optional validation | `-validation-` | `single` | `true` | + +Each group belongs to the router's `uses` job, not to `Plan`, each matrix leg, or every nested workflow. [Reusable-workflow calling jobs support concurrency][reusable]. The processing workflow does not reacquire the router's group. Only one track job is eligible per invocation. + +For example, this production-job excerpt depends on the preflight job; its reusable target contains the complete processing DAG: + +```yaml +jobs: + Production: + needs: Record-Invocation + if: >- + (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && + github.ref == format('refs/heads/{0}', github.event.repository.default_branch) + concurrency: + group: Process-PSModule-${{ github.workflow }}-production + queue: max + cancel-in-progress: false + uses: ./.github/workflows/Process.yml + secrets: + PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} + GitHubAppClientId: ${{ secrets.GitHubAppClientId }} + GitHubAppPrivateKey: ${{ secrets.GitHubAppPrivateKey }} + TestData: ${{ secrets.TestData }} + with: + SettingsPath: ${{ inputs.SettingsPath }} + WorkingDirectory: ${{ inputs.WorkingDirectory }} + ImportantFilePatterns: ${{ inputs.ImportantFilePatterns }} + Debug: ${{ inputs.Debug }} + Verbose: ${{ inputs.Verbose }} + Version: ${{ inputs.Version }} + Prerelease: ${{ inputs.Prerelease }} +``` + +The public inputs and secrets remain unchanged. Internal filenames and job identifiers do not become caller inputs. The extra nesting level stays within GitHub's reusable-workflow depth limit. Changing job paths requires updating required checks rather than silently removing a merge gate. + +### Minimal caller + +The caller subscribes to default-branch pushes and all supported PR actions. It has **no workflow-level or calling-job concurrency block**. Manual runs and schedules are optional, not prerequisites for the three tracks. + +```yaml +name: Process-PSModule + +on: + push: + branches: [main] + pull_request: + branches: [main] + types: [opened, reopened, synchronize, labeled, unlabeled, closed] + +permissions: {} + +jobs: + Process-PSModule: + permissions: + contents: read + pages: write + id-token: write + uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v8 + secrets: + PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} + GitHubAppClientId: ${{ secrets.SHELLY_CLIENT_ID }} + GitHubAppPrivateKey: ${{ secrets.SHELLY_PRIVATE_KEY }} +``` + +This is the caller contract for the router design, not evidence that an arbitrary existing `v8` pin implements it. Consumers select a framework release carrying the contract during rollout. Test data and other optional inputs follow the [workflow input contract](../../reference/workflow-inputs.md). + +Caller path filters must not suppress required closure handling. Duplicating subscriptions in multiple caller workflows, using `pull_request_target` as an interchangeable event, or adding an outer concurrency block is outside this standard caller contract. No inner workflow can restore work discarded by an outer queue. + +### Production and activity execution + +Production holds admission across planning, quality gates, publication, documentation deployment, and enabled teardown. Every checkout and publication target uses the run's recorded commit, not a freshly resolved branch tip. Version resolution reads the release state after prior admitted production work terminates. + +PR preflight checks the live PR's open state, head SHA, lifecycle, and relevant release labels against its recorded event before requesting activity admission. The processing workflow repeats the check before expensive work and immediately before external publication. A mismatch marks the invocation obsolete rather than applying new labels to an old plan. Obsolete or closed activity reports why it stops. This also handles delayed events and reruns of obsolete revisions; arrival order alone does not establish freshness. + +The preflight read and native admission are not atomic. An event delayed between them can still displace current work. If no current eligible invocation remains, the surviving stale invocation reports that the latest revision needs a full rerun; it does not publish or report a successful current validation. Normal ordered bursts converge automatically, but native cancellation is not a strict latest-commit scheduler. + +Native cancellation supersedes the activity invocation, but already-running teardown or an accepted external request may finish. Freshness checks reduce stale writes; they are not an atomic transaction with PR updates. Publication records and close reconciliation handle interrupted writes rather than assuming cancellation undoes them. + +### Closure coordination + +The close path has its own retained admission group. Its cancellation requests use the existing GitHub App identity with repository-scoped Actions write permission. + +1. Capture the PR number, closure timestamp, and lifecycle boundary from the close event and authenticated PR timeline. A lifecycle starts at opening or reopening; the boundary is fixed for the closure attempt. +2. Enumerate activity runs for the same repository and caller workflow, across every result page and all nonterminal statuses. Verify PR association and lifecycle before targeting a run. Exclude production, validation, other PRs, this closure, and all other closure runs. +3. Request cancellation of matching running or pending activity. Track run ID and attempt; a cancellation response of `202` acknowledges a request, not termination. On `409`, reread the run before deciding that concurrent completion explains the conflict. +4. Poll targeted runs and reconcile newly visible matching activity until all are terminal. Allow up to **10 minutes from the start of cancellation coordination**, including discovery and receipt availability, honoring API retry guidance. Expiry or unresolved association fails the close run before deletion; automatic force-cancellation is not used. +5. Load cleanup policy from a pinned, trusted default-branch revision, honoring `SettingsPath` and `WorkingDirectory`. With `Publish.Module.AutoCleanup: false`, report retention and stop without deleting resources. Omission means `true`. +6. With cleanup enabled, acquire the per-PR cleanup resource lock, reconcile recorded publication intents, enumerate owned resources, and delete only verified candidates. Report every outcome. + +The [cancellation process][cancellation] permits conditions such as `always()` to keep jobs running. A successful cancellation HTTP request is never the cleanup barrier. Close coordination uses the [workflow-run REST API][runs], not a shell command that merely submits cancellation. + +The run list is not an atomic snapshot. Filtered queries have a result ceiling, so enumeration partitions time ranges or reports incompleteness rather than trusting a full-sized truncated result. `gh run list` is not the ownership source: its default is limited and it excludes PR association payloads. + +The preflight uploads an immutable, attempt-specific invocation receipt as an Actions artifact before admitting any track. It records the original event action and lifecycle without credentials or PR body content. Closure reads it through authenticated artifact metadata for that exact workflow run and attempt. This distinguishes activity from other close runs: the run-list API's `event=pull_request` filter alone cannot distinguish their actions. + +For runs created before closure, authenticated workflow/PR association and the closure boundary identify candidates; the receipt proves activity classification and lifecycle. A not-yet-started run can need time to produce its receipt. Missing, expired, or ambiguous evidence remains subject to the coordination deadline and fails quiescence rather than authorizing cancellation of unrelated work. Receipt retention covers the supported active-run lifetime; a repository retention policy that cannot preserve it is reported during adoption. + +Delayed pre-close events that become runs after the closure boundary fail the live-state/lifecycle admission check before publishing. A reopened PR's new activity and publication records belong to another lifecycle and are preserved, even if the earlier cleanup starts late. An old run attempt cannot acquire a new lifecycle merely by being rerun. + +### Resource reconciliation + +Publication writes durable ownership before its first externally visible package publication: a framework-authored annotated prerelease tag and draft prerelease release carry the provenance below. Prerelease identities incorporate the owning run and attempt in a SemVer- and Gallery-compatible form and are never reused by another owner, even after deletion. Existing resources with conflicting provenance are rejected, not overwritten. The draft becomes the published GitHub prerelease when publication succeeds. + +This record allows cleanup to find interrupted publication even when no published GitHub Release exists. Tag enumeration and release enumeration are both paginated. A branch-name substring, sanitized branch name, or commit SHA alone is not proof of ownership. Human release notes are separate from machine-readable provenance. + +Cleanup selects records for the closing lifecycle, including earlier still-unreconciled lifecycles of that PR, but excludes every later reopening. It verifies the resource remains a prerelease or an owned prerelease reservation, excludes stable and retained publication tags, and confirms the tag target and recorded identity before deletion. Releases are deleted by exact release ID; tag deletion is a separate verified operation. Release `created_at` is not a lifecycle cutoff because GitHub defines it from the associated commit, not the publication time. + +Deletion is repeatable: a resource already absent after a previously verified deletion is successful; authentication errors, conflicting identity, incomplete inventories, and other unresolved API failures are not. A final reconciliation reports any remaining candidate. Ambiguous legacy resources remain untouched with a failing, actionable report rather than a false claim of complete cleanup. + +Cancellation cannot prove that an already-accepted remote publication was rolled back. Reconciliation resolves recorded intents against the remote resources; an unsettled external operation remains incomplete and requires retry. PowerShell Gallery package versions remain published; cleanup never claims to remove them. + +Both close-triggered cleanup and the existing successful stable-publication cleanup fallback use the same resource coordinator. Its separate job-scoped group, `-cleanup-resources-pr-`, uses `queue: max` and no cancellation. It serializes deletion only, never reacquires an admission key, and retains the fixed lifecycle boundary. Close cleanup does not depend on stable publication succeeding. + +## Data and contracts + +| Record | Fields and use | +| --- | --- | +| Invocation | Repository identity, caller workflow identity, event/action, original event timestamp, ref, commit SHA, run ID, run attempt, and optional PR number/lifecycle. | +| Closure | PR number, fixed close timestamp/lifecycle, pinned settings revision, configured cleanup value, targeted run IDs/attempts, and cancellation deadline. | +| Prerelease provenance | Schema version, repository identity, module identity, PR number, lifecycle, originating run ID/attempt, head SHA, exact version/tag and expected target, release ID once created, and publication intent. Stored on the annotated tag and draft/published release. | +| Cleanup result | Candidates, deleted/already-absent/retained/ambiguous resources, failures, and whether enumeration and quiescence were complete. | + +No scheduling value comes from settings loaded by `Plan`: job outputs arrive too late for whole-pipeline admission. The existing `Publish.Module.AutoCleanup` authoring key remains the only deletion switch; configured intent and effective event routing are recorded separately. + +## Security + +Normal processing retains the established secret boundary. The trusted close path runs framework-owned code, does not run module tests or checkout executable PR content, and loads only configuration data from the pinned default branch. + +Cancellation uses an installation token narrowed to the caller repository with Actions write and only the read permissions required for ownership checks. Deletion uses a separate token narrowed to Contents write. Tokens are step-scoped; default `github.token` permissions do not implicitly grant or restrict these App tokens. + +The GitHub App installation must grant the added Actions permission; no new secret is required. Fork PRs and Dependabot runs do not automatically receive privileged secrets. The standard caller does not promise privileged cleanup in those contexts; an explicitly designed trusted handoff is required. Substituting `pull_request_target` and running PR code with secrets is not that handoff. + +## Testing strategy + +| Coverage | Required evidence | +| --- | --- | +| Routing contracts | Table-driven tests for every event/action, custom default branches, merged and abandoned closure, unsupported events, and no publication from validation. | +| Caller and nesting contracts | No outer concurrency; literal queue policies; one eligible track; every input/secret forwarded; no nested reacquisition; required-check names accounted for. | +| Production admission | A disposable nonpublishing harness holds A while B and C wait; all complete serially and retain their SHAs. Include push/manual overlap and a failed A. | +| Capacity boundary | In a controlled harness, hold one active invocation, admit 100 pending, then exceed capacity and record the extra invocation's platform cancellation. Do not generate real releases for this test. | +| PR convergence | Three rapid pushes, repeated head SHA with changed labels, label removal, delayed delivery, obsolete reruns, and two independent PRs. | +| Close barrier | Cancel accepted but still running, `always()` teardown, concurrent completion/409, pagination, ambiguous membership, deadline expiry, and run-attempt changes. Assert zero deletion before the barrier. | +| Cleanup ownership | Multiple pages, branch-name collisions, missing branches/labels, interrupted draft/tag creation, already-absent resources, legacy ambiguity, and preserved stable/other-PR/later-lifecycle resources. | +| Lifecycle integration | Close during publication, reopen before cleanup, duplicate close, simultaneous merge push, disabled cleanup, and overlapping stable fallback cleanup. | + +Use the existing Pester action-test runner for pure routing, ownership, and mocked API behavior. Real GitHub integration evidence is required for scheduling and nested cancellation; parser success and mocked tests cannot establish those platform properties. Public package registries are not test targets. + +## Rollout and operability + +Adoption couples a framework release containing the router with removal of caller concurrency. Until both changes are present, the caller's old one-pending policy can still discard production work or prevent PR supersession. The setup guide, calling guide, fixture callers, module template, and v8 upgrade guidance carry the same caller contract. + +The additional App Actions permission, receipt retention, changed required-check paths, trusted cleanup settings source, and non-reused prerelease identity/provenance format are explicit migration items. Legacy prereleases are inventoried before enabling destructive cleanup; ambiguous ownership requires operator reconciliation, not automatic substring deletion. A dry-run report uses the same enumeration and ownership checks without mutation. + +Admission overflow can occur before a processing job runs, so GitHub's run status is the authoritative overflow signal. Operators inspect canceled/expired production entries and use controlled full-run recovery after reconciling publication state. Publication rejects planning outputs from a different run attempt. Partial reruns that reuse old planning outputs are not a fresh production release; recovery must reenter the serialization boundary and must not republish an already-published version. + +Close summaries include the canceled activity, lifecycle cutoff, policy revision, deleted/retained resources, and failures. A failed barrier is retried only after checking the named activity; failed deletion is rerun against the same ownership scope. Neither failure is hidden behind an unconditional successful summary. + +[concurrency]: https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency +[limits]: https://docs.github.com/en/actions/reference/limits +[reusable]: https://docs.github.com/en/actions/reference/workflows-and-actions/reusing-workflow-configurations +[cancellation]: https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-cancellation +[runs]: https://docs.github.com/en/rest/actions/workflow-runs diff --git a/docs/content/specification/workflow-triggers/index.md b/docs/content/specification/workflow-triggers/index.md new file mode 100644 index 00000000..7ced4605 --- /dev/null +++ b/docs/content/specification/workflow-triggers/index.md @@ -0,0 +1,15 @@ +--- +title: Workflow triggers +description: The scheduling and cleanup contract for publishing runs, pull-request updates, and pull-request closure. +--- + +# Workflow triggers + +Process-PSModule separates production delivery, replaceable pull-request feedback, and prerelease cleanup. + +| Page | Owns | +| --- | --- | +| [Spec](spec.md) | Required behavior, isolation, capacity, and acceptance scenarios. | +| [Design](design.md) | Event routing, concurrency ownership, cancellation, and cleanup coordination. | + +The [framework spec](../spec.md) owns build, test, versioning, and publication requirements. diff --git a/docs/content/specification/workflow-triggers/spec.md b/docs/content/specification/workflow-triggers/spec.md index 88279e06..c653c4fd 100644 --- a/docs/content/specification/workflow-triggers/spec.md +++ b/docs/content/specification/workflow-triggers/spec.md @@ -79,6 +79,8 @@ An update to an open pull request MUST request cancellation of older running wor The rule applies to revision updates and supported release-intent changes, including label removal. Cancellation MAY allow already-started steps or external requests to finish. A burst therefore guarantees latest-state convergence, not exactly one workflow start. +Supersession follows admission order, not a guaranteed chronological delivery order. If delayed delivery displaces the current revision and leaves only obsolete work, the framework MUST reject stale work and report that the current revision needs a rerun rather than report current validation as successful. + #### Supersession scenarios ```gherkin @@ -201,6 +203,7 @@ Scenario: Clean an abandoned change with the standard caller ## Constraints and assumptions - **Constraint:** Platform scheduling and cancellation are asynchronous. Cancellation does not undo PowerShell Gallery uploads or already-accepted API requests. +- **Constraint:** Native supersession selects the latest admitted invocation. Freshness checks prevent obsolete publication but do not make event delivery and cancellation atomic. - **Constraint:** The caller controls event delivery, filtering, permissions, and any outer concurrency policy. The framework cannot recover events the caller never invokes. - **Constraint:** Guarantees apply within documented platform queue and runtime limits, not to arbitrary external cancellation or failed quality gates. - **Assumption:** A repository has one production caller for a module. Independent modules or unrelated workflows are not coordinated unless they share an explicit framework identity. diff --git a/docs/zensical.toml b/docs/zensical.toml index 9c9dbd78..302eba52 100644 --- a/docs/zensical.toml +++ b/docs/zensical.toml @@ -55,6 +55,11 @@ nav = [ "specification/index.md", {"Spec" = "specification/spec.md"}, {"Design" = "specification/design.md"}, + {"Workflow triggers" = [ + "specification/workflow-triggers/index.md", + {"Spec" = "specification/workflow-triggers/spec.md"}, + {"Design" = "specification/workflow-triggers/design.md"}, + ]}, {"Principles and practices" = "specification/principles-and-practices.md"}, ]}, {"Modules" = "Modules/index.md"}, From a5b4e3007eab10c4e3fa2f3598cf87a8bc536067 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Fri, 4 Sep 2026 23:43:49 +0200 Subject: [PATCH 03/13] Document nested workflow permission boundary --- docs/content/specification/workflow-triggers/design.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/content/specification/workflow-triggers/design.md b/docs/content/specification/workflow-triggers/design.md index e765cd93..d5646ff6 100644 --- a/docs/content/specification/workflow-triggers/design.md +++ b/docs/content/specification/workflow-triggers/design.md @@ -87,6 +87,10 @@ The framework namespace is `Process-PSModule-${{ github.workflow }}`. One stable Each group belongs to the router's `uses` job, not to `Plan`, each matrix leg, or every nested workflow. [Reusable-workflow calling jobs support concurrency][reusable]. The processing workflow does not reacquire the router's group. Only one track job is eligible per invocation. +With a default-deny workflow permission floor, every router job calling a nested reusable workflow MUST declare the +least-privilege permissions its child requires. A nested workflow can restrict, but cannot elevate, its caller's +`GITHUB_TOKEN`. This is independent of any scoped GitHub App token minted inside the child. + For example, this production-job excerpt depends on the preflight job; its reusable target contains the complete processing DAG: ```yaml @@ -100,6 +104,10 @@ jobs: group: Process-PSModule-${{ github.workflow }}-production queue: max cancel-in-progress: false + permissions: + contents: read + pages: write + id-token: write uses: ./.github/workflows/Process.yml secrets: PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} From ada8a70f695c23b7f6e3da4d865cfa17fab18689 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Fri, 4 Sep 2026 23:57:53 +0200 Subject: [PATCH 04/13] Record simplified concurrency experiment result --- docs/content/specification/workflow-triggers/design.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/content/specification/workflow-triggers/design.md b/docs/content/specification/workflow-triggers/design.md index d5646ff6..47e66dab 100644 --- a/docs/content/specification/workflow-triggers/design.md +++ b/docs/content/specification/workflow-triggers/design.md @@ -15,7 +15,7 @@ The reusable entry point, `.github/workflows/workflow.yml`, routes events into c Use three mutually exclusive entry jobs with literal concurrency policies. The production and activity jobs each call the complete processing workflow, retaining their concurrency slot until its nested jobs finish. The close job calls only closure coordination and cleanup. A small framework-owned preflight records invocation identity before these jobs become eligible; it performs no planning, version resolution, or module execution. -This avoids an expression-valued `queue`, whose official parser support is stronger than its documented examples, and avoids a short-lived admission job that releases its slot before the pipeline starts. `Plan` and version resolution execute inside the processing call, after admission. +This avoids an expression-valued `queue` and a short-lived admission job that releases its slot before the pipeline starts. A live GitHub.com experiment accepted an expression-valued `queue` for an uncontended run and for pull-request replacement, but concurrent production invocations failed before creating jobs rather than entering the `max` queue. The framework therefore uses literal policies. `Plan` and version resolution execute inside the processing call, after admission. ### Platform contract @@ -36,7 +36,7 @@ These are [GitHub's concurrency guarantees][concurrency] and [Actions limits][li | Option | Trade-offs | Verdict | | --- | --- | --- | | Internal router with literal per-track policies | Adds one reusable-workflow layer; keeps caller configuration small and protects the complete pipeline. | Chosen. | -| Conditional workflow-level concurrency | Fewer internal jobs, but conditional `queue` needs live-service evidence beyond parser support. | Not required for this design. | +| One conditional workflow-level group | Fewer internal jobs, but a conditional `queue` plus conditional cancellation fails concurrent production admission before jobs run. A static `max` queue cannot cancel PR activity; a static `single` queue cannot retain the production burst. | Rejected. | | Caller-owned concurrency | Can protect the entire caller, but duplicates policy and can discard work before the framework receives it. | Rejected for the standard caller. | | One cancelable group for activity and closure | Close can supersede activity natively, but reopening or another update can cancel cleanup. | Rejected. | | Concurrency only on publication or on a short admission job | Allows planning/version races or releases the lock before processing ends. | Rejected. | From 56805294b9940c95982ae04b7979c075d337c1e8 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 5 Sep 2026 00:10:30 +0200 Subject: [PATCH 05/13] Clarify caller-side static serialization tradeoff --- docs/content/specification/workflow-triggers/design.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/content/specification/workflow-triggers/design.md b/docs/content/specification/workflow-triggers/design.md index 47e66dab..0b70c3c2 100644 --- a/docs/content/specification/workflow-triggers/design.md +++ b/docs/content/specification/workflow-triggers/design.md @@ -15,7 +15,7 @@ The reusable entry point, `.github/workflows/workflow.yml`, routes events into c Use three mutually exclusive entry jobs with literal concurrency policies. The production and activity jobs each call the complete processing workflow, retaining their concurrency slot until its nested jobs finish. The close job calls only closure coordination and cleanup. A small framework-owned preflight records invocation identity before these jobs become eligible; it performs no planning, version resolution, or module execution. -This avoids an expression-valued `queue` and a short-lived admission job that releases its slot before the pipeline starts. A live GitHub.com experiment accepted an expression-valued `queue` for an uncontended run and for pull-request replacement, but concurrent production invocations failed before creating jobs rather than entering the `max` queue. The framework therefore uses literal policies. `Plan` and version resolution execute inside the processing call, after admission. +This avoids an expression-valued `queue` and a short-lived admission job that releases its slot before the pipeline starts. A live GitHub.com experiment accepted an expression-valued `queue` for an uncontended run and for pull-request replacement, but concurrent production invocations failed before creating jobs rather than entering the `max` queue. The same top-level producer group with literal `max` and `false` also failed under production contention when its key contained `github.workflow` and the PR-or-ref fallback. The equivalent static group worked at the caller workflow level. The framework therefore uses literal policies on internal router jobs. `Plan` and version resolution execute inside the processing call, after admission. ### Platform contract @@ -36,8 +36,9 @@ These are [GitHub's concurrency guarantees][concurrency] and [Actions limits][li | Option | Trade-offs | Verdict | | --- | --- | --- | | Internal router with literal per-track policies | Adds one reusable-workflow layer; keeps caller configuration small and protects the complete pipeline. | Chosen. | -| One conditional workflow-level group | Fewer internal jobs, but a conditional `queue` plus conditional cancellation fails concurrent production admission before jobs run. A static `max` queue cannot cancel PR activity; a static `single` queue cannot retain the production burst. | Rejected. | -| Caller-owned concurrency | Can protect the entire caller, but duplicates policy and can discard work before the framework receives it. | Rejected for the standard caller. | +| One conditional producer workflow-level group | Fewer internal jobs, but a conditional `queue` plus conditional cancellation fails concurrent production admission before jobs run. A static group using the same `github.workflow` and PR-or-ref identity also fails under production contention. | Rejected. | +| One caller-side static workflow-level group | `queue: max` and `cancel-in-progress: false` retain and serialize every production event, PR update, and closure for a stable PR-or-ref key. The exact expression works as a caller workflow's concurrency group. | Rejected for the standard caller: the caller owns policy, PR updates do not converge, and closure waits behind obsolete activity. | +| Caller-owned concurrency | Can protect caller-side sibling work, but duplicates policy and can discard work before the framework receives it when it uses the wrong queue policy. | Rejected for the standard caller. | | One cancelable group for activity and closure | Close can supersede activity natively, but reopening or another update can cancel cleanup. | Rejected. | | Concurrency only on publication or on a short admission job | Allows planning/version races or releases the lock before processing ends. | Rejected. | | Durable external dispatcher | Can provide stronger ordering and retention but adds persistent state and operational ownership. | Outside the bounded native-queue contract. | From 305562ffae2da9a0a32363a9d91627c9e5aa7f0b Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 5 Sep 2026 00:15:00 +0200 Subject: [PATCH 06/13] Record conditional caller concurrency experiment --- docs/content/specification/workflow-triggers/design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/content/specification/workflow-triggers/design.md b/docs/content/specification/workflow-triggers/design.md index 0b70c3c2..73b9bffc 100644 --- a/docs/content/specification/workflow-triggers/design.md +++ b/docs/content/specification/workflow-triggers/design.md @@ -37,7 +37,7 @@ These are [GitHub's concurrency guarantees][concurrency] and [Actions limits][li | --- | --- | --- | | Internal router with literal per-track policies | Adds one reusable-workflow layer; keeps caller configuration small and protects the complete pipeline. | Chosen. | | One conditional producer workflow-level group | Fewer internal jobs, but a conditional `queue` plus conditional cancellation fails concurrent production admission before jobs run. A static group using the same `github.workflow` and PR-or-ref identity also fails under production contention. | Rejected. | -| One caller-side static workflow-level group | `queue: max` and `cancel-in-progress: false` retain and serialize every production event, PR update, and closure for a stable PR-or-ref key. The exact expression works as a caller workflow's concurrency group. | Rejected for the standard caller: the caller owns policy, PR updates do not converge, and closure waits behind obsolete activity. | +| One conditional caller workflow-level group | `queue: max` without cancellation for pushes, plus `queue: single` with cancellation for PR events, successfully retained three serialized production runs, converged PR updates, and canceled activity before the closure run in a live caller experiment. | Rejected for the standard caller: the caller owns policy, and activity after reopening can still cancel close cleanup because closure shares the cancelable PR group. | | Caller-owned concurrency | Can protect caller-side sibling work, but duplicates policy and can discard work before the framework receives it when it uses the wrong queue policy. | Rejected for the standard caller. | | One cancelable group for activity and closure | Close can supersede activity natively, but reopening or another update can cancel cleanup. | Rejected. | | Concurrency only on publication or on a short admission job | Allows planning/version races or releases the lock before processing ends. | Rejected. | From b6dde9d7d74e30af3b5b93f0abb779c4bef008cd Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 5 Sep 2026 00:37:44 +0200 Subject: [PATCH 07/13] Adopt minimal caller concurrency policy --- docs/content/get-started/repository-setup.md | 7 +- docs/content/guides/calling-the-workflow.md | 12 +- docs/content/specification/design.md | 8 +- docs/content/specification/spec.md | 2 +- .../specification/workflow-triggers/design.md | 280 ++++-------------- .../specification/workflow-triggers/spec.md | 229 ++++++-------- 6 files changed, 165 insertions(+), 373 deletions(-) diff --git a/docs/content/get-started/repository-setup.md b/docs/content/get-started/repository-setup.md index 790cf5ea..ce50e7d9 100644 --- a/docs/content/get-started/repository-setup.md +++ b/docs/content/get-started/repository-setup.md @@ -54,7 +54,8 @@ on: concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: false + queue: ${{ github.event_name == 'pull_request' && 'single' || 'max' }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} permissions: contents: read @@ -75,6 +76,10 @@ the pull-request trigger handles CI, prereleases, and prerelease cleanup. See [Workflow inputs](../reference/workflow-inputs.md) for what each permission is used for, and [Calling the workflow](../guides/calling-the-workflow.md) for passing test secrets and variables. +The caller-level concurrency block retains production and manual work while replacing obsolete work for the same pull +request. The fallback expression uses the pull-request number for every pull-request action, including `closed`; other +events use their Git ref. Keep its group distinct from the reusable workflow's prefixed group. + ## 4. Add the settings file Create `.github/PSModule.yml`. An empty file is valid — every setting has a default: diff --git a/docs/content/guides/calling-the-workflow.md b/docs/content/guides/calling-the-workflow.md index 8e18d7e1..dc58858c 100644 --- a/docs/content/guides/calling-the-workflow.md +++ b/docs/content/guides/calling-the-workflow.md @@ -37,7 +37,8 @@ on: concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: false + queue: ${{ github.event_name == 'pull_request' && 'single' || 'max' }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} permissions: contents: read @@ -59,11 +60,10 @@ Stable releases are evaluated from a push to the default branch. A merged pull r release notes; a direct default-branch push or a manual dispatch uses the default `Patch` bump and commit-based notes. Keep the `pull_request` trigger for CI, prereleases, and prerelease cleanup. -The concurrency key keeps a pull request distinct from a default-branch push, so the close-event cleanup and the -resulting stable release do not serialize as one run. Keep `cancel-in-progress: false`: a release-capable run mutates -the PowerShell Gallery, GitHub Releases, and tags, so later runs must queue rather than interrupt it. -The reusable workflow uses its own prefixed concurrency group, so it cannot queue behind the caller while the caller -waits for it to finish. +The concurrency key keeps each pull request distinct from the default branch, so a close event interrupts only its own +pull-request activity and does not block the resulting stable release. Pull-request events replace obsolete activity; +all other events, including a manual default-branch release, retain the maximum native queue. The reusable workflow +uses a distinct prefixed concurrency group. Do not give the caller the reusable workflow's group name. ## Passing test data diff --git a/docs/content/specification/design.md b/docs/content/specification/design.md index c9dbbe8d..3a26f182 100644 --- a/docs/content/specification/design.md +++ b/docs/content/specification/design.md @@ -19,10 +19,10 @@ documented in [Workflow inputs](../reference/workflow-inputs.md). ### Trigger admission -The [workflow trigger design](workflow-triggers/design.md) owns admission before processing: a retained production queue, -replaceable pull-request activity, and protected closure coordination. Concurrency belongs to the reusable entry point, -not to caller templates or individual processing stages. The complete processing call retains its admission slot through -the final enabled stage; closure stops obsolete activity before optional prerelease cleanup. +The [workflow trigger design](workflow-triggers/design.md) owns caller-level admission before processing: a retained +production queue and replaceable pull-request activity. The caller's workflow-level group covers the complete reusable +workflow call through its final enabled stage; the reusable workflow identifies closure and performs optional prerelease +cleanup. ### Composed reusable workflows diff --git a/docs/content/specification/spec.md b/docs/content/specification/spec.md index ed0a6c57..b5280323 100644 --- a/docs/content/specification/spec.md +++ b/docs/content/specification/spec.md @@ -61,7 +61,7 @@ Versions MUST follow [SemVer 2.0.0](https://semver.org/) (`vMAJOR.MINOR.PATCH` o ### NFR2 — Serialized releases {#nfr2} -Production pipelines MUST execute serially from planning through their final enabled stage, retaining pending work within the platform's maximum queue capacity. Pull-request updates MUST supersede obsolete activity without canceling production or closure cleanup. The [workflow trigger spec](workflow-triggers/spec.md) owns admission, ordering boundaries, and cleanup guarantees. +Production pipelines MUST execute serially from planning through their final enabled stage, retaining pending work within the platform's maximum queue capacity. Pull-request updates MUST supersede obsolete activity without canceling production or work for other pull requests; closing a pull request supersedes its activity. The [workflow trigger spec](workflow-triggers/spec.md) owns admission, ordering boundaries, and cleanup guarantees. ### NFR3 — Single production authority {#nfr3} diff --git a/docs/content/specification/workflow-triggers/design.md b/docs/content/specification/workflow-triggers/design.md index 73b9bffc..3271572a 100644 --- a/docs/content/specification/workflow-triggers/design.md +++ b/docs/content/specification/workflow-triggers/design.md @@ -1,256 +1,106 @@ --- title: Workflow triggers - Design -description: Framework-owned native admission with retained production work, replaceable PR activity, and cancellation-aware cleanup. +description: Use one caller-owned GitHub Actions concurrency group for retained production and replaceable pull-request work. --- # Workflow triggers - Design -The reusable entry point, `.github/workflows/workflow.yml`, routes events into concurrency-controlled calls to processing or cleanup workflows. Native admission owns queuing; the close path reconciles cancellation and resource ownership before deletion. No external queue service or caller-side dispatcher is required. +The caller workflow holds one concurrency slot for its complete call to `.github/workflows/workflow.yml`. It retains +non-pull-request work, replaces activity for an individual pull request, and lets the reusable workflow select its +normal processing or close behavior from the incoming event. ## Specification -[Workflow triggers - Spec](spec.md) defines the behavior. The [framework design](../design.md) owns the processing stages and settings contract. +[Workflow triggers - Spec](spec.md) defines the required admission behavior. ## Approach -Use three mutually exclusive entry jobs with literal concurrency policies. The production and activity jobs each call the complete processing workflow, retaining their concurrency slot until its nested jobs finish. The close job calls only closure coordination and cleanup. A small framework-owned preflight records invocation identity before these jobs become eligible; it performs no planning, version resolution, or module execution. +Every caller uses this workflow-level configuration: -This avoids an expression-valued `queue` and a short-lived admission job that releases its slot before the pipeline starts. A live GitHub.com experiment accepted an expression-valued `queue` for an uncontended run and for pull-request replacement, but concurrent production invocations failed before creating jobs rather than entering the `max` queue. The same top-level producer group with literal `max` and `false` also failed under production contention when its key contained `github.workflow` and the PR-or-ref fallback. The equivalent static group worked at the caller workflow level. The framework therefore uses literal policies on internal router jobs. `Plan` and version resolution execute inside the processing call, after admission. +```yaml +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + queue: ${{ github.event_name == 'pull_request' && 'single' || 'max' }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} +``` -### Platform contract +The group expression is a fallback, not concatenation. A pull-request event uses its number; every other event uses +its Git ref. The caller workflow name scopes the group from other workflow names in the same repository, so no +additional prefix is needed. -| Concern | Contract | -| --- | --- | -| Retained queue | `queue: max` permits **100 pending executions plus one running execution** per group. | -| Overflow | Additional executions are canceled when the pending queue is full; the limit cannot be increased by support. | -| Replacement queue | `queue: single` is the default. A newcomer replaces an existing pending execution even when `cancel-in-progress` is `false`. | -| Ordering | FIFO by the time work starts waiting for the concurrency group, **not** by push, commit, or workflow-dispatch time. | -| Valid combinations | `queue: max` requires `cancel-in-progress: false` or omission. Combining it with `true` is invalid. | -| Group scope | Repository-local and case-insensitive. Caller and called workflow must not acquire the same group. | -| Other limits | The workflow lifetime limit includes waiting; event-trigger rate limits also apply. A 100-entry group is not an unlimited durable queue. | +The queue expression distinguishes the two policies: -These are [GitHub's concurrency guarantees][concurrency] and [Actions limits][limits]. The native queue meets the accepted bounded-retention contract, not strict chronological push ordering. +- Pull-request events use the one-pending replacement queue and cancel active work. +- All other events use the maximum retained queue and do not cancel active work. This includes `push`, + `workflow_dispatch`, and any scheduled event. -### Alternatives considered +## Alternatives considered | Option | Trade-offs | Verdict | | --- | --- | --- | -| Internal router with literal per-track policies | Adds one reusable-workflow layer; keeps caller configuration small and protects the complete pipeline. | Chosen. | -| One conditional producer workflow-level group | Fewer internal jobs, but a conditional `queue` plus conditional cancellation fails concurrent production admission before jobs run. A static group using the same `github.workflow` and PR-or-ref identity also fails under production contention. | Rejected. | -| One conditional caller workflow-level group | `queue: max` without cancellation for pushes, plus `queue: single` with cancellation for PR events, successfully retained three serialized production runs, converged PR updates, and canceled activity before the closure run in a live caller experiment. | Rejected for the standard caller: the caller owns policy, and activity after reopening can still cancel close cleanup because closure shares the cancelable PR group. | -| Caller-owned concurrency | Can protect caller-side sibling work, but duplicates policy and can discard work before the framework receives it when it uses the wrong queue policy. | Rejected for the standard caller. | -| One cancelable group for activity and closure | Close can supersede activity natively, but reopening or another update can cancel cleanup. | Rejected. | -| Concurrency only on publication or on a short admission job | Allows planning/version races or releases the lock before processing ends. | Rejected. | -| Durable external dispatcher | Can provide stronger ordering and retention but adds persistent state and operational ownership. | Outside the bounded native-queue contract. | +| One conditional caller workflow-level group | Covers the complete reusable-workflow call with three expressions and preserves the required event behavior. | Chosen. | +| One conditional producer workflow-level group | A live experiment failed concurrent production admission before jobs began. | Rejected. | +| One caller group with `queue: max` and no cancellation | Retains every event but does not converge pull-request activity or prioritize closure. | Rejected. | +| Per-track router jobs | Separates closure from activity but adds nested reusable workflows, receipts, and coordination beyond the required caller setting. | Rejected. | +| External dispatcher | Provides stronger retention and ordering at the cost of persistent state and operational ownership. | Rejected. | ## Architecture -```mermaid -flowchart TD - Caller["Caller: events, permissions, secrets"] --> Receipt["Record invocation identity"] - Receipt --> Router["workflow.yml: event routing"] - Router --> Production["Production admission: max queue, no cancellation"] - Router --> Activity["PR activity admission: single queue, cancel older work"] - Router --> Close["Close admission: max queue, no cancellation"] - Production --> Process["Processing workflow: Plan through final enabled stage"] - Activity --> Process - Close --> Stop["Cancel matching PR activity and await termination"] - Stop --> Policy["Load trusted cleanup settings"] - Policy --> Cleanup["Reconcile and delete exactly owned prereleases"] - Policy --> Retain["Report intentional retention"] -``` - -### Event routing - -Routing uses the original `github` event context. The called workflow sees the caller's context; it does not receive `workflow_call` as a replacement business event. Routing does not require the settings file. - -| Event | Track | Execution | -| --- | --- | --- | -| `push` to `github.event.repository.default_branch` | Production | Full processing; existing change detection and release gates still apply. | -| `workflow_dispatch` on the default branch | Production | Same serialization and stable-release policy as the supported manual path. | -| `pull_request`: `opened`, `reopened`, `synchronize`, `labeled`, `unlabeled` | PR activity | Replace obsolete work; validate live PR state before processing and publication. | -| `pull_request`: `closed`, whether merged or abandoned | Close | Stop activity, then evaluate optional cleanup; never enter the processing DAG. | -| Optional `schedule`, or manual validation on another branch | Validation | Nonpublishing processing, isolated from production and closure; latest-per-ref admission. | -| Other events or actions | Unsupported | Explicitly report no supported route; never fall through into publication. | - -Default-branch identity comes from the event's repository metadata rather than a hard-coded `main`. Caller branch filters still name the repository's actual default branch. A merged close event can have the default-branch `github.ref`, so PR identity always uses the PR number, not that ref. - -### Admission boundary and keys - -The framework namespace is `Process-PSModule-${{ github.workflow }}`. One stable-named caller owns a module's production path; different caller names are not a substitute for coordinating two publishers of the same module. - -| Track | Group suffix | Queue | Cancel in progress | -| --- | --- | --- | --- | -| Production | `-production` | `max` | `false` | -| PR activity | `-pr-` | `single` | `true` | -| Close | `-close-pr-` | `max` | `false` | -| Optional validation | `-validation-` | `single` | `true` | - -Each group belongs to the router's `uses` job, not to `Plan`, each matrix leg, or every nested workflow. [Reusable-workflow calling jobs support concurrency][reusable]. The processing workflow does not reacquire the router's group. Only one track job is eligible per invocation. - -With a default-deny workflow permission floor, every router job calling a nested reusable workflow MUST declare the -least-privilege permissions its child requires. A nested workflow can restrict, but cannot elevate, its caller's -`GITHUB_TOKEN`. This is independent of any scoped GitHub App token minted inside the child. - -For example, this production-job excerpt depends on the preflight job; its reusable target contains the complete processing DAG: - -```yaml -jobs: - Production: - needs: Record-Invocation - if: >- - (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && - github.ref == format('refs/heads/{0}', github.event.repository.default_branch) - concurrency: - group: Process-PSModule-${{ github.workflow }}-production - queue: max - cancel-in-progress: false - permissions: - contents: read - pages: write - id-token: write - uses: ./.github/workflows/Process.yml - secrets: - PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} - GitHubAppClientId: ${{ secrets.GitHubAppClientId }} - GitHubAppPrivateKey: ${{ secrets.GitHubAppPrivateKey }} - TestData: ${{ secrets.TestData }} - with: - SettingsPath: ${{ inputs.SettingsPath }} - WorkingDirectory: ${{ inputs.WorkingDirectory }} - ImportantFilePatterns: ${{ inputs.ImportantFilePatterns }} - Debug: ${{ inputs.Debug }} - Verbose: ${{ inputs.Verbose }} - Version: ${{ inputs.Version }} - Prerelease: ${{ inputs.Prerelease }} -``` - -The public inputs and secrets remain unchanged. Internal filenames and job identifiers do not become caller inputs. The extra nesting level stays within GitHub's reusable-workflow depth limit. Changing job paths requires updating required checks rather than silently removing a merge gate. - -### Minimal caller - -The caller subscribes to default-branch pushes and all supported PR actions. It has **no workflow-level or calling-job concurrency block**. Manual runs and schedules are optional, not prerequisites for the three tracks. - -```yaml -name: Process-PSModule - -on: - push: - branches: [main] - pull_request: - branches: [main] - types: [opened, reopened, synchronize, labeled, unlabeled, closed] - -permissions: {} - -jobs: - Process-PSModule: - permissions: - contents: read - pages: write - id-token: write - uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v8 - secrets: - PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} - GitHubAppClientId: ${{ secrets.SHELLY_CLIENT_ID }} - GitHubAppPrivateKey: ${{ secrets.SHELLY_PRIVATE_KEY }} -``` - -This is the caller contract for the router design, not evidence that an arbitrary existing `v8` pin implements it. Consumers select a framework release carrying the contract during rollout. Test data and other optional inputs follow the [workflow input contract](../../reference/workflow-inputs.md). - -Caller path filters must not suppress required closure handling. Duplicating subscriptions in multiple caller workflows, using `pull_request_target` as an interchangeable event, or adding an outer concurrency block is outside this standard caller contract. No inner workflow can restore work discarded by an outer queue. - -### Production and activity execution - -Production holds admission across planning, quality gates, publication, documentation deployment, and enabled teardown. Every checkout and publication target uses the run's recorded commit, not a freshly resolved branch tip. Version resolution reads the release state after prior admitted production work terminates. - -PR preflight checks the live PR's open state, head SHA, lifecycle, and relevant release labels against its recorded event before requesting activity admission. The processing workflow repeats the check before expensive work and immediately before external publication. A mismatch marks the invocation obsolete rather than applying new labels to an old plan. Obsolete or closed activity reports why it stops. This also handles delayed events and reruns of obsolete revisions; arrival order alone does not establish freshness. - -The preflight read and native admission are not atomic. An event delayed between them can still displace current work. If no current eligible invocation remains, the surviving stale invocation reports that the latest revision needs a full rerun; it does not publish or report a successful current validation. Normal ordered bursts converge automatically, but native cancellation is not a strict latest-commit scheduler. - -Native cancellation supersedes the activity invocation, but already-running teardown or an accepted external request may finish. Freshness checks reduce stale writes; they are not an atomic transaction with PR updates. Publication records and close reconciliation handle interrupted writes rather than assuming cancellation undoes them. - -### Closure coordination - -The close path has its own retained admission group. Its cancellation requests use the existing GitHub App identity with repository-scoped Actions write permission. - -1. Capture the PR number, closure timestamp, and lifecycle boundary from the close event and authenticated PR timeline. A lifecycle starts at opening or reopening; the boundary is fixed for the closure attempt. -2. Enumerate activity runs for the same repository and caller workflow, across every result page and all nonterminal statuses. Verify PR association and lifecycle before targeting a run. Exclude production, validation, other PRs, this closure, and all other closure runs. -3. Request cancellation of matching running or pending activity. Track run ID and attempt; a cancellation response of `202` acknowledges a request, not termination. On `409`, reread the run before deciding that concurrent completion explains the conflict. -4. Poll targeted runs and reconcile newly visible matching activity until all are terminal. Allow up to **10 minutes from the start of cancellation coordination**, including discovery and receipt availability, honoring API retry guidance. Expiry or unresolved association fails the close run before deletion; automatic force-cancellation is not used. -5. Load cleanup policy from a pinned, trusted default-branch revision, honoring `SettingsPath` and `WorkingDirectory`. With `Publish.Module.AutoCleanup: false`, report retention and stop without deleting resources. Omission means `true`. -6. With cleanup enabled, acquire the per-PR cleanup resource lock, reconcile recorded publication intents, enumerate owned resources, and delete only verified candidates. Report every outcome. - -The [cancellation process][cancellation] permits conditions such as `always()` to keep jobs running. A successful cancellation HTTP request is never the cleanup barrier. Close coordination uses the [workflow-run REST API][runs], not a shell command that merely submits cancellation. - -The run list is not an atomic snapshot. Filtered queries have a result ceiling, so enumeration partitions time ranges or reports incompleteness rather than trusting a full-sized truncated result. `gh run list` is not the ownership source: its default is limited and it excludes PR association payloads. - -The preflight uploads an immutable, attempt-specific invocation receipt as an Actions artifact before admitting any track. It records the original event action and lifecycle without credentials or PR body content. Closure reads it through authenticated artifact metadata for that exact workflow run and attempt. This distinguishes activity from other close runs: the run-list API's `event=pull_request` filter alone cannot distinguish their actions. - -For runs created before closure, authenticated workflow/PR association and the closure boundary identify candidates; the receipt proves activity classification and lifecycle. A not-yet-started run can need time to produce its receipt. Missing, expired, or ambiguous evidence remains subject to the coordination deadline and fails quiescence rather than authorizing cancellation of unrelated work. Receipt retention covers the supported active-run lifetime; a repository retention policy that cannot preserve it is reported during adoption. - -Delayed pre-close events that become runs after the closure boundary fail the live-state/lifecycle admission check before publishing. A reopened PR's new activity and publication records belong to another lifecycle and are preserved, even if the earlier cleanup starts late. An old run attempt cannot acquire a new lifecycle merely by being rerun. - -### Resource reconciliation - -Publication writes durable ownership before its first externally visible package publication: a framework-authored annotated prerelease tag and draft prerelease release carry the provenance below. Prerelease identities incorporate the owning run and attempt in a SemVer- and Gallery-compatible form and are never reused by another owner, even after deletion. Existing resources with conflicting provenance are rejected, not overwritten. The draft becomes the published GitHub prerelease when publication succeeds. - -This record allows cleanup to find interrupted publication even when no published GitHub Release exists. Tag enumeration and release enumeration are both paginated. A branch-name substring, sanitized branch name, or commit SHA alone is not proof of ownership. Human release notes are separate from machine-readable provenance. +| Component | Responsibility | +| --- | --- | +| Caller workflow | Subscribes to events and applies the concurrency group before invoking the reusable workflow. | +| GitHub Actions | Retains non-pull-request work or cancels superseded pull-request work for the matching group. | +| `workflow.yml` | Receives the original caller event and routes it through the existing processing or close path. | -Cleanup selects records for the closing lifecycle, including earlier still-unreconciled lifecycles of that PR, but excludes every later reopening. It verifies the resource remains a prerelease or an owned prerelease reservation, excludes stable and retained publication tags, and confirms the tag target and recorded identity before deletion. Releases are deleted by exact release ID; tag deletion is a separate verified operation. Release `created_at` is not a lifecycle cutoff because GitHub defines it from the associated commit, not the publication time. +The caller-level group covers all nested reusable jobs until the calling job completes. It therefore serializes planning, +version resolution, publication, and enabled teardown rather than only a short admission step. -Deletion is repeatable: a resource already absent after a previously verified deletion is successful; authentication errors, conflicting identity, incomplete inventories, and other unresolved API failures are not. A final reconciliation reports any remaining candidate. Ambiguous legacy resources remain untouched with a failing, actionable report rather than a false claim of complete cleanup. +## Data and contracts -Cancellation cannot prove that an already-accepted remote publication was rolled back. Reconciliation resolves recorded intents against the remote resources; an unsettled external operation remains incomplete and requires retry. PowerShell Gallery package versions remain published; cleanup never claims to remove them. +| Event | Group identity | Queue | Cancel active work | Result | +| --- | --- | --- | --- | --- | +| Default-branch `push` | `-refs/heads/` | `max` | No | Retained production work. | +| Default-branch `workflow_dispatch` | `-refs/heads/` | `max` | No | Retained manual production work. | +| Open pull-request activity | `-` | `single` | Yes | Latest activity for that pull request. | +| `pull_request.closed` | `-` | `single` | Yes | Cancels activity and invokes the close path. | +| Scheduled event | `-` | `max` | No | Retained non-pull-request work. | -Both close-triggered cleanup and the existing successful stable-publication cleanup fallback use the same resource coordinator. Its separate job-scoped group, `-cleanup-resources-pr-`, uses `queue: max` and no cancellation. It serializes deletion only, never reacquires an admission key, and retains the fixed lifecycle boundary. Close cleanup does not depend on stable publication succeeding. +A merge's close event has the same pull-request number as its activity even when its Git ref resolves to the default +branch. It therefore cancels activity in the pull-request group, while the resulting default-branch push uses a +different group and proceeds independently. -## Data and contracts +## Reusable workflow compatibility -| Record | Fields and use | -| --- | --- | -| Invocation | Repository identity, caller workflow identity, event/action, original event timestamp, ref, commit SHA, run ID, run attempt, and optional PR number/lifecycle. | -| Closure | PR number, fixed close timestamp/lifecycle, pinned settings revision, configured cleanup value, targeted run IDs/attempts, and cancellation deadline. | -| Prerelease provenance | Schema version, repository identity, module identity, PR number, lifecycle, originating run ID/attempt, head SHA, exact version/tag and expected target, release ID once created, and publication intent. Stored on the annotated tag and draft/published release. | -| Cleanup result | Candidates, deleted/already-absent/retained/ambiguous resources, failures, and whether enumeration and quiescence were complete. | +The caller group is deliberately unprefixed. The current reusable workflow uses +`Process-PSModule-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}`. The names differ, +which prevents a cancellation-enabled caller group from canceling its own caller job through the called workflow. -No scheduling value comes from settings loaded by `Plan`: job outputs arrive too late for whole-pipeline admission. The existing `Publish.Module.AutoCleanup` authoring key remains the only deletion switch; configured intent and effective event routing are recorded separately. +Do not add `Process-PSModule-` to the caller group while that reusable group exists. The group names are +repository-local and case-insensitive. If a future reusable workflow removes its group, this caller configuration +remains valid without modification. ## Security -Normal processing retains the established secret boundary. The trusted close path runs framework-owned code, does not run module tests or checkout executable PR content, and loads only configuration data from the pinned default branch. - -Cancellation uses an installation token narrowed to the caller repository with Actions write and only the read permissions required for ownership checks. Deletion uses a separate token narrowed to Contents write. Tokens are step-scoped; default `github.token` permissions do not implicitly grant or restrict these App tokens. - -The GitHub App installation must grant the added Actions permission; no new secret is required. Fork PRs and Dependabot runs do not automatically receive privileged secrets. The standard caller does not promise privileged cleanup in those contexts; an explicitly designed trusted handoff is required. Substituting `pull_request_target` and running PR code with secrets is not that handoff. +Concurrency uses only GitHub event metadata. It requires no secrets and grants no additional permissions. The existing +reusable workflow retains responsibility for credential handling and for evaluating the trusted cleanup setting. ## Testing strategy -| Coverage | Required evidence | -| --- | --- | -| Routing contracts | Table-driven tests for every event/action, custom default branches, merged and abandoned closure, unsupported events, and no publication from validation. | -| Caller and nesting contracts | No outer concurrency; literal queue policies; one eligible track; every input/secret forwarded; no nested reacquisition; required-check names accounted for. | -| Production admission | A disposable nonpublishing harness holds A while B and C wait; all complete serially and retain their SHAs. Include push/manual overlap and a failed A. | -| Capacity boundary | In a controlled harness, hold one active invocation, admit 100 pending, then exceed capacity and record the extra invocation's platform cancellation. Do not generate real releases for this test. | -| PR convergence | Three rapid pushes, repeated head SHA with changed labels, label removal, delayed delivery, obsolete reruns, and two independent PRs. | -| Close barrier | Cancel accepted but still running, `always()` teardown, concurrent completion/409, pagination, ambiguous membership, deadline expiry, and run-attempt changes. Assert zero deletion before the barrier. | -| Cleanup ownership | Multiple pages, branch-name collisions, missing branches/labels, interrupted draft/tag creation, already-absent resources, legacy ambiguity, and preserved stable/other-PR/later-lifecycle resources. | -| Lifecycle integration | Close during publication, reopen before cleanup, duplicate close, simultaneous merge push, disabled cleanup, and overlapping stable fallback cleanup. | +Use a disposable, nonpublishing caller and producer. Verify three competing production pushes finish serially, a +manual run waits behind production, rapid updates cancel activity for one pull request without affecting another, and +a close event cancels activity before the close path runs. -Use the existing Pester action-test runner for pure routing, ownership, and mocked API behavior. Real GitHub integration evidence is required for scheduling and nested cancellation; parser success and mocked tests cannot establish those platform properties. Public package registries are not test targets. +Live GitHub Actions experiments validated the configuration with a distinct prefixed group in the reusable producer: +three production runs completed serially; a manual run waited behind a push and executed the production path; three +rapid pull-request activity runs were canceled; and the closure run completed its close job. A separate experiment +showed that placing the conditional group in the producer fails under concurrent production admission. ## Rollout and operability -Adoption couples a framework release containing the router with removal of caller concurrency. Until both changes are present, the caller's old one-pending policy can still discard production work or prevent PR supersession. The setup guide, calling guide, fixture callers, module template, and v8 upgrade guidance carry the same caller contract. - -The additional App Actions permission, receipt retention, changed required-check paths, trusted cleanup settings source, and non-reused prerelease identity/provenance format are explicit migration items. Legacy prereleases are inventoried before enabling destructive cleanup; ambiguous ownership requires operator reconciliation, not automatic substring deletion. A dry-run report uses the same enumeration and ownership checks without mutation. - -Admission overflow can occur before a processing job runs, so GitHub's run status is the authoritative overflow signal. Operators inspect canceled/expired production entries and use controlled full-run recovery after reconciling publication state. Publication rejects planning outputs from a different run attempt. Partial reruns that reuse old planning outputs are not a fresh production release; recovery must reenter the serialization boundary and must not republish an already-published version. - -Close summaries include the canceled activity, lifecycle cutoff, policy revision, deleted/retained resources, and failures. A failed barrier is retried only after checking the named activity; failed deletion is rerun against the same ownership scope. Neither failure is hidden behind an unconditional successful summary. +Replace the caller's existing `cancel-in-progress: false` block with the documented configuration. No new input, +secret, dispatcher, or reusable-workflow version is required. Keep the group distinct from the reusable workflow's +prefixed group. -[concurrency]: https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency -[limits]: https://docs.github.com/en/actions/reference/limits -[reusable]: https://docs.github.com/en/actions/reference/workflows-and-actions/reusing-workflow-configurations -[cancellation]: https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-cancellation -[runs]: https://docs.github.com/en/rest/actions/workflow-runs +GitHub retains one running and up to 100 pending executions for a `max` group. It does not guarantee commit-order +execution. Overflow, manual cancellation, and external publication results remain visible in Actions; concurrency +cancellation does not undo an already accepted external operation. diff --git a/docs/content/specification/workflow-triggers/spec.md b/docs/content/specification/workflow-triggers/spec.md index c653c4fd..c36fe51f 100644 --- a/docs/content/specification/workflow-triggers/spec.md +++ b/docs/content/specification/workflow-triggers/spec.md @@ -1,218 +1,155 @@ --- title: Workflow triggers - Spec -description: Preserve production runs, replace obsolete pull-request work, and clean up owned prereleases after closure. +description: Retain production work while superseding obsolete pull-request activity with one caller concurrency group. --- # Workflow triggers - Spec -Process-PSModule admits work into independent production, pull-request, and closure tracks. Production runs retain accepted work; pull-request runs favor the latest revision; closure stops obsolete work before optionally removing its prereleases. Module repositories select events and provide credentials, not scheduling algorithms. +The caller workflow admits Process-PSModule work with one native GitHub Actions concurrency group. Default-branch +production and manual releases are retained; activity for each pull request converges on its latest event; a close +event supersedes that pull request's activity before the reusable workflow evaluates optional prerelease cleanup. ## Problem -One scheduling policy cannot safely serve both production publication and rapid pull-request updates. Replacing queued production work loses delivery intent; retaining obsolete pull-request work delays feedback. Cleanup that races a publisher can leave orphaned prereleases or remove another pull request's output. +Production publication cannot safely lose queued runs, while rapid pull-request updates should not consume resources +after they are obsolete. Both event types invoke the same reusable workflow, so admission must distinguish a +default-branch Git ref from a pull-request number without adding workflow jobs or a dispatcher. ## Outcomes and impact -- **Outcome:** Accepted production events reach a terminal result without replacement by later events, while obsolete pull-request runs yield to current work. -- **DORA:** Shorter feedback lead time without increasing publication failures caused by overlapping runs. -- **Domain signal:** Zero production events evicted within supported queue capacity, zero overlapping production pipelines, and zero owned GitHub prereleases remaining after successful enabled cleanup. +- **Outcome:** Production work is retained, and a pull request receives feedback for its latest admitted state. +- **DORA:** Shorter pull-request feedback lead time without losing accepted production delivery intent. +- **Domain signal:** No accepted production event is replaced within the native queue capacity. ## Users and jobs -Module maintainers publish accepted changes reliably. Contributors receive feedback for the latest pull-request state. Repository operators choose whether to retain prereleases and can identify failed, canceled, or capacity-rejected work. +Module maintainers retain stable publication runs and manual releases. Contributors avoid waiting for obsolete +pull-request activity. Repository operators retain the existing close-triggered prerelease cleanup setting. ## Scope -**In scope:** Event admission, track isolation, production serialization, pull-request supersession, closure cancellation, prerelease cleanup, and the minimum caller contract. +**In scope:** Caller admission, production serialization, pull-request supersession, and admission of close events. -**Out of scope:** Version-label policy, quality gates, module build logic, merge-queue integration, and changing the production branch model. +**Out of scope:** An external queue, custom dispatcher jobs, version-label policy, build logic, and prerelease +ownership or deletion implementation. ## Non-goals -- Unlimited durable event storage or guaranteed completion despite platform outages, failures, manual cancellation, or timeouts. -- Debouncing that prevents every obsolete run from starting. -- Deleting or unlisting PowerShell Gallery packages, or treating cancellation as rollback of an external publication. +- Unlimited event storage or strict chronological execution when GitHub delivery order differs from push order. +- Preventing every obsolete run from starting before a later event arrives. +- Restoring external publication after cancellation. ## Functional requirements -### FR1 - Central scheduling policy {#fr1} +### FR1 - Admit work through one caller policy {#fr1} -The framework MUST own track selection, queue policy, and cancellation rules. A caller MUST NOT need concurrency expressions, a dispatcher job, or separate jobs for each track. Callers MUST subscribe to the required events and supply the established credentials and permissions. Cleanup configuration MUST use the existing repository settings contract. +The caller workflow MUST define one workflow-level concurrency group for the complete reusable-workflow call. The +group MUST use the pull-request number when available and the Git ref otherwise. It MUST retain non-pull-request work +and replace pull-request work. The reusable workflow selects processing or close behavior from the original event. -#### Caller scenario +#### Caller admission scenario ```gherkin -Scenario: Use the standard caller - Given a caller with the required event subscriptions, permissions, and secrets - And no caller concurrency block or scheduling inputs - When supported events invoke the framework - Then the framework selects their tracks without additional caller logic +Scenario: Call the workflow + Given a caller subscribes to default-branch and pull-request events + And it declares the documented concurrency group + When it calls Process-PSModule + Then its complete reusable-workflow call has one admission policy + And no dispatcher or per-track caller jobs are required ``` ### FR2 - Retain and serialize production work {#fr2} -Each push to the repository default branch, normally `main`, MUST enter the production track. All supported stable-publication entry points MUST share its serialization boundary. At most one production pipeline MUST execute at a time, from planning and version resolution through its final enabled stage. +Each default-branch push and manual default-branch release MUST share a retained group. A later admitted production +event MUST NOT cancel a running production pipeline or replace an accepted pending production event within supported +capacity. The complete pipeline executes at most once at a time and retains its triggering revision. -A later event MUST NOT cancel a running production pipeline or replace an accepted pending production event within supported capacity. Pending events MUST execute sequentially in admission order, subject to the platform ordering boundary in the design. Each run MUST retain its triggering revision; it MUST NOT silently build a newer branch tip after waiting. Admission does not bypass important-file, release-label, or quality gates. - -#### Production scenarios +#### Production retention scenario ```gherkin Scenario: Preserve a production burst Given production run A is executing - When pushes B and C are admitted in that order - Then A is not canceled by either push - And B and C remain pending - And B executes after A terminates - And C executes after B terminates - And each run uses its own triggering revision - -Scenario: Share the production authority - Given a production push run is executing - When a supported manual stable-publication run is admitted - Then the manual run waits in the same production track + When pushes B and C are admitted + Then A is not canceled + And B and C wait and execute serially + And each run uses its triggering revision ``` -### FR3 - Supersede obsolete pull-request work {#fr3} - -An update to an open pull request MUST request cancellation of older running work and replace older pending work for that pull request. Different pull requests MUST NOT cancel one another. Once updates stop and cancellation settles, only the latest eligible state proceeds; obsolete work MUST NOT begin another publication after failing a freshness check. - -The rule applies to revision updates and supported release-intent changes, including label removal. Cancellation MAY allow already-started steps or external requests to finish. A burst therefore guarantees latest-state convergence, not exactly one workflow start. +### FR3 - Supersede pull-request activity {#fr3} -Supersession follows admission order, not a guaranteed chronological delivery order. If delayed delivery displaces the current revision and leaves only obsolete work, the framework MUST reject stale work and report that the current revision needs a rerun rather than report current validation as successful. +An `opened`, `reopened`, `synchronize`, `labeled`, or `unlabeled` event MUST cancel running activity and replace +pending activity for the same pull request. Different pull requests and production work MUST remain independent. +Once updates stop, the latest admitted pull-request activity proceeds. -#### Supersession scenarios +#### Pull-request activity scenario ```gherkin Scenario: Push three revisions rapidly - Given revision A is running for pull request 42 - When revisions B and C arrive in that order before A finishes - And no later updates arrive - Then cancellation is requested for superseded work - And only C proceeds after cancellation settles - And production runs and pull request 43 are unaffected - -Scenario: Remove prerelease intent - Given a pull-request run is eligible to publish a prerelease - When the prerelease label is removed before publication begins - Then the older run is superseded - And its publication eligibility is rechecked before any new publication + Given revision A is processing for pull request 42 + When revisions B and C arrive + Then superseded activity for pull request 42 is canceled + And only the latest admitted activity proceeds + And production and pull request 43 are unaffected ``` -### FR4 - Close, stop, then clean {#fr4} +### FR4 - Supersede activity on closure {#fr4} -Both merged and abandoned pull requests MUST enter the closure track. Closure MUST request cancellation of running and pending activity belonging to the closing pull-request lifecycle, then wait for that activity to reach a terminal state before deleting prereleases. Closure MUST NOT build, test, publish a module, deploy a site, or authorize a stable release. +A merged or abandoned pull request MUST use the same pull-request group as its activity. Its `closed` event MUST +cancel outstanding activity for that pull request and invoke the reusable workflow's close path. The close path MUST +NOT authorize build, test, stable publication, or site deployment. It evaluates prerelease cleanup through the +existing repository setting. -Closure runs MUST NOT be canceled by production pushes, pull-request updates, or later closure events. A merge's closure and default-branch push MUST remain separate operations. Closing obsolete activity is independent of whether prerelease deletion is enabled. - -#### Closure scenarios +#### Closure scenario ```gherkin -Scenario: Close during prerelease publication - Given a pull-request activity run is publishing a prerelease - When that pull request closes - Then the closure run requests cancellation of that activity - And it waits for the activity to terminate before deleting prereleases - And it reports incomplete cleanup if termination cannot be confirmed - -Scenario: Merge creates two independent operations - Given a prerelease pull request is merged - When its closure event and default-branch push are admitted - Then closure stops obsolete pull-request work and evaluates cleanup - And the push enters the production queue - And neither operation cancels the other -``` - -### FR5 - Optional, exact, repeatable cleanup {#fr5} - -Automatic cleanup MUST be enabled by default and MUST be disableable through the established cleanup setting. Enabled cleanup MUST enumerate all GitHub prerelease releases and tags provably owned by the closing pull-request lifecycle, including recoverable partial publication records. It MUST preserve stable releases, other pull requests' resources, and resources from a later reopening. - -Cleanup MUST work without the prerelease label still being present, without important-file changes, and without the source branch still existing. Repeating successful cleanup MUST succeed with nothing left to delete. Ambiguous ownership, incomplete enumeration, or an unresolved deletion MUST be reported, not presented as successful complete cleanup. - -#### Cleanup scenarios - -```gherkin -Scenario: Retain prereleases by configuration - Given automatic cleanup is disabled - When a pull request closes - Then obsolete activity is stopped - And no prerelease release or tag is deleted - And retention is reported as intentional - -Scenario: Clean beyond the first page - Given a closed pull request owns prereleases spanning multiple result pages - And its prerelease label and source branch have been removed - When enabled cleanup succeeds - Then all its owned GitHub prerelease releases and tags are absent - And stable releases and other pull requests' resources are unchanged - -Scenario: Reopen while cleanup is pending - Given a closure run is pending for an earlier pull-request lifecycle - When the pull request reopens and produces another prerelease - Then the earlier closure neither cancels the reopened activity nor deletes its output +Scenario: Close during pull-request activity + Given pull-request activity is running for pull request 42 + When pull request 42 closes + Then its activity is canceled + And the close path runs after cancellation + And a default-branch production run is unaffected ``` ## Non-functional requirements -### NFR1 - Maximum supported queue capacity {#nfr1} - -The production track MUST use the platform's maximum supported pending-run capacity, with no smaller framework or caller limit. Capacity and ordering guarantees MUST be documented precisely. Overflow or expiry MUST remain visible as unsuccessful admission or execution; it MUST NOT be described as successful delivery. - -#### Capacity scenario - -```gherkin -Scenario: Fill the production queue - Given one production run is active - When pending runs fill the documented maximum capacity - Then every admitted pending run is retained - When another run exceeds capacity - Then the platform's rejection or cancellation is visible - And no retained run is silently replaced by the framework -``` - -### NFR2 - Bounded and diagnosable cleanup {#nfr2} +### NFR1 - Use maximum native retention {#nfr1} -Every executing closure run MUST record its pull request, lifecycle boundary, cleanup decision, targeted run IDs, and resource outcomes without exposing secrets. Waiting for canceled activity MUST have a finite documented deadline. Deadline expiry MUST fail cleanup without beginning deletion; enumeration and deletion failures MUST produce a failing result with enough identity to retry safely. +The production group MUST use GitHub's `max` queue: one running execution and up to 100 pending executions. Overflow +remains visible as a canceled execution; it MUST NOT be reported as retained work. -### NFR3 - Narrow authority {#nfr3} +### NFR2 - Keep lifecycle identities isolated {#nfr2} -Cancellation and deletion MUST be restricted to the caller repository and verified framework-owned work. Cleanup MUST NOT execute untrusted pull-request code with write credentials. Unsupported credential contexts MUST fail or explicitly skip privileged work; they MUST NOT appear to have cleaned resources. +Concurrency identity MUST distinguish every pull request from all Git refs. A closed pull request uses its number, +not the default-branch ref associated with a merge. The caller group MUST differ from any reusable-workflow group to +avoid recursive cancellation. ## Acceptance criteria ```gherkin -# AC1 - Verifies: FR2, FR3, FR4, FR5, NFR2 -Scenario: Production burst during pull-request closure - Given one production run and one prerelease pull-request run are active - When two more production pushes arrive and the pull request closes - Then all three production runs remain serialized and are not superseded - And the closing pull-request activity is canceled before cleanup deletes anything - And successful enabled cleanup leaves no owned GitHub prereleases - And the closure run records what was stopped and removed - -# AC2 - Verifies: FR1, FR4, FR5, NFR3 -Scenario: Clean an abandoned change with the standard caller - Given the standard caller and default cleanup settings - And an abandoned pull request owns several prereleases - When it closes without merging - Then cleanup runs without a special caller job - And no stable release is created - And only resources with verified ownership are deleted +# AC1 - Verifies: FR1, FR2, FR3, FR4, NFR1, NFR2 +Scenario: Production and pull-request lifecycle + Given a production run and pull-request activity are running + When two production pushes, two pull-request updates, and a close event arrive + Then production work completes serially + And obsolete pull-request activity is canceled + And the close path executes + And no production run is canceled by pull-request activity or closure ``` ## Constraints and assumptions -- **Constraint:** Platform scheduling and cancellation are asynchronous. Cancellation does not undo PowerShell Gallery uploads or already-accepted API requests. -- **Constraint:** Native supersession selects the latest admitted invocation. Freshness checks prevent obsolete publication but do not make event delivery and cancellation atomic. -- **Constraint:** The caller controls event delivery, filtering, permissions, and any outer concurrency policy. The framework cannot recover events the caller never invokes. -- **Constraint:** Guarantees apply within documented platform queue and runtime limits, not to arbitrary external cancellation or failed quality gates. -- **Assumption:** A repository has one production caller for a module. Independent modules or unrelated workflows are not coordinated unless they share an explicit framework identity. +- **Constraint:** GitHub queues by when an execution starts waiting, not strict push or commit chronology. +- **Constraint:** Cancellation is asynchronous and does not roll back an already accepted external operation. +- **Constraint:** A later event for a reopened pull request can supersede a still-running close path; cleanup checks + the live lifecycle before mutation. +- **Assumption:** Each repository uses one named caller workflow for Process-PSModule. ## Dependencies -- [Framework spec](../spec.md) - release authority and publication requirements. -- [Settings](../../reference/settings.md) - authorable cleanup policy. +- [GitHub concurrency](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency) + - queue and cancellation semantics. +- [Settings](../../reference/settings.md) - existing prerelease cleanup setting. ## Where this connects -- [Design](design.md) - scheduling boundaries, platform limits, and cleanup implementation. +- [Design](design.md) - caller configuration and compatibility. From ea463381a957f04c95b0a18ddf9cbbd384ae5fd3 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 5 Sep 2026 00:52:00 +0200 Subject: [PATCH 08/13] Document valid caller concurrency policies GitHub Actions requires static queue values and rejects queue: max with cancellation. Separate production and pull-request caller jobs preserve both required behaviors. Co-authored-by: Copilot <223556698+Copilot@users.noreply.github.com> --- .../skills/psmodule-v8-upgrade/SKILL.md | 35 +++++--- docs/content/get-started/repository-setup.md | 48 ++++++---- docs/content/guides/calling-the-workflow.md | 90 ++++++++++--------- .../guides/github-app-authentication.md | 11 ++- docs/content/reference/repository-standard.md | 12 ++- docs/content/specification/design.md | 7 +- .../specification/workflow-triggers/design.md | 65 +++++++------- .../specification/workflow-triggers/spec.md | 23 ++--- 8 files changed, 171 insertions(+), 120 deletions(-) diff --git a/.github/plugin/psmodule/skills/psmodule-v8-upgrade/SKILL.md b/.github/plugin/psmodule/skills/psmodule-v8-upgrade/SKILL.md index 0c27e026..a5bb8ee1 100644 --- a/.github/plugin/psmodule/skills/psmodule-v8-upgrade/SKILL.md +++ b/.github/plugin/psmodule/skills/psmodule-v8-upgrade/SKILL.md @@ -165,14 +165,30 @@ on: - labeled - unlabeled -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} - permissions: {} jobs: - Process-PSModule: + Process-PSModule-Production: + if: ${{ github.event_name != 'pull_request' }} + concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + queue: max + permissions: + contents: read + pages: write + id-token: write + uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v8 + secrets: + PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} + GitHubAppClientId: ${{ secrets.SHELLY_CLIENT_ID }} + GitHubAppPrivateKey: ${{ secrets.SHELLY_PRIVATE_KEY }} + + Process-PSModule-PullRequest: + if: ${{ github.event_name == 'pull_request' }} + concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + queue: single + cancel-in-progress: true permissions: contents: read pages: write @@ -184,11 +200,10 @@ jobs: GitHubAppPrivateKey: ${{ secrets.SHELLY_PRIVATE_KEY }} ``` -The only permitted variation is an optional `TestData` entry under -`jobs.Process-PSModule.secrets`. Do not add `with:` inputs, extra jobs, -conditions, schedule changes, `run-name`, permission changes, trigger changes, -concurrency changes, debug options, or version overrides. Repository-owned -automation belongs in separate workflow files. +The only permitted variation is an optional identical `TestData` entry under both caller jobs' `secrets` mappings. +Do not add `with:` inputs, extra jobs, conditions, schedule changes, `run-name`, permission changes, trigger changes, +concurrency changes, debug options, or version overrides. Repository-owned automation belongs in separate workflow +files. ### TestData preservation diff --git a/docs/content/get-started/repository-setup.md b/docs/content/get-started/repository-setup.md index ce50e7d9..fa72496a 100644 --- a/docs/content/get-started/repository-setup.md +++ b/docs/content/get-started/repository-setup.md @@ -52,18 +52,34 @@ on: - labeled - unlabeled -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - queue: ${{ github.event_name == 'pull_request' && 'single' || 'max' }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} - -permissions: - contents: read - pages: write - id-token: write +permissions: {} jobs: - Process-PSModule: + Process-PSModule-Production: + if: ${{ github.event_name != 'pull_request' }} + concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + queue: max + permissions: + contents: read + pages: write + id-token: write + uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v8 + secrets: + PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} + GitHubAppClientId: ${{ secrets.SHELLY_CLIENT_ID }} + GitHubAppPrivateKey: ${{ secrets.SHELLY_PRIVATE_KEY }} + + Process-PSModule-PullRequest: + if: ${{ github.event_name == 'pull_request' }} + concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + queue: single + cancel-in-progress: true + permissions: + contents: read + pages: write + id-token: write uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v8 secrets: PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} @@ -71,14 +87,16 @@ jobs: GitHubAppPrivateKey: ${{ secrets.SHELLY_PRIVATE_KEY }} ``` -Every permission in that block is required. GitHub App installation tokens perform repository writes. A push to `main` publishes a stable release after the full pipeline passes; -the pull-request trigger handles CI, prereleases, and prerelease cleanup. See +Every permission on the calling jobs is required. GitHub App installation tokens perform repository writes. A push to +`main` publishes a stable release after the full pipeline passes; the pull-request trigger handles CI, prereleases, +and prerelease cleanup. See [Workflow inputs](../reference/workflow-inputs.md) for what each permission is used for, and [Calling the workflow](../guides/calling-the-workflow.md) for passing test secrets and variables. -The caller-level concurrency block retains production and manual work while replacing obsolete work for the same pull -request. The fallback expression uses the pull-request number for every pull-request action, including `closed`; other -events use their Git ref. Keep its group distinct from the reusable workflow's prefixed group. +The production job retains pushes, dispatches, and scheduled work in the maximum native queue. The pull-request job +cancels obsolete activity and uses the pull-request number for every action, including `closed`. GitHub requires a +literal queue value and does not permit cancellation with `queue: max`, so the two jobs use separate compatible +policies. Keep both groups distinct from the reusable workflow's prefixed group. ## 4. Add the settings file diff --git a/docs/content/guides/calling-the-workflow.md b/docs/content/guides/calling-the-workflow.md index dc58858c..70194d7e 100644 --- a/docs/content/guides/calling-the-workflow.md +++ b/docs/content/guides/calling-the-workflow.md @@ -35,18 +35,34 @@ on: - labeled - unlabeled -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - queue: ${{ github.event_name == 'pull_request' && 'single' || 'max' }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} - -permissions: - contents: read - pages: write - id-token: write +permissions: {} jobs: - Process-PSModule: + Process-PSModule-Production: + if: ${{ github.event_name != 'pull_request' }} + concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + queue: max + permissions: + contents: read + pages: write + id-token: write + uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v8 + secrets: + PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} + GitHubAppClientId: ${{ secrets.SHELLY_CLIENT_ID }} + GitHubAppPrivateKey: ${{ secrets.SHELLY_PRIVATE_KEY }} + + Process-PSModule-PullRequest: + if: ${{ github.event_name == 'pull_request' }} + concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + queue: single + cancel-in-progress: true + permissions: + contents: read + pages: write + id-token: write uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v8 secrets: PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} @@ -60,10 +76,11 @@ Stable releases are evaluated from a push to the default branch. A merged pull r release notes; a direct default-branch push or a manual dispatch uses the default `Patch` bump and commit-based notes. Keep the `pull_request` trigger for CI, prereleases, and prerelease cleanup. -The concurrency key keeps each pull request distinct from the default branch, so a close event interrupts only its own -pull-request activity and does not block the resulting stable release. Pull-request events replace obsolete activity; -all other events, including a manual default-branch release, retain the maximum native queue. The reusable workflow -uses a distinct prefixed concurrency group. Do not give the caller the reusable workflow's group name. +The production job serializes default-branch pushes, manual releases, and scheduled work in the maximum native queue. +The pull-request job uses a separate group for each pull request, so a close event interrupts only its own activity and +does not block the resulting stable release. Its `single` queue and cancellation replace obsolete activity. GitHub +requires a literal queue value and rejects cancellation with `queue: max`, so these policies must remain separate. +The reusable workflow uses a distinct prefixed concurrency group. Do not give either caller job that group name. ## Passing test data @@ -85,21 +102,14 @@ The reusable workflow accepts test data through `TestData` and no longer declare - `TEST_USER_USER_FG_PAT` - `TEST_USER_PAT` -If a caller passed any of these secrets directly, place them in the `secrets` map inside `TestData`. -The environment variable names used by the tests can stay the same; only the workflow-call interface -changes: +If a caller passed any of these secrets directly, place them in the `secrets` map inside `TestData`. Add the same +`TestData` mapping to the `secrets` block of both caller jobs. The environment variable names used by the tests can +stay the same; only the workflow-call interface changes: ```yaml -jobs: - Process-PSModule: - uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v8 - secrets: - PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} - GitHubAppClientId: ${{ secrets.SHELLY_CLIENT_ID }} - GitHubAppPrivateKey: ${{ secrets.SHELLY_PRIVATE_KEY }} - TestData: >- - { "secrets": { "TEST_USER_PAT": "${{ secrets.TEST_USER_PAT }}", - "TEST_APP_ORG_CLIENT_ID": "${{ secrets.TEST_APP_ORG_CLIENT_ID }}" } } +TestData: >- + { "secrets": { "TEST_USER_PAT": "${{ secrets.TEST_USER_PAT }}", + "TEST_APP_ORG_CLIENT_ID": "${{ secrets.TEST_APP_ORG_CLIENT_ID }}" } } ``` ### Passing test phase data (secrets and variables) @@ -112,25 +122,17 @@ workflow. It is one JSON object with two maps, so everything the tests need is v { "secrets": { "NAME": "value" }, "variables": { "NAME": "value" } } ``` -Values under `secrets` are masked in the logs; values under `variables` are not. Build it in the -calling workflow and pass it through the `secrets:` block (so the whole blob is masked). Reference each -secret directly as `"${{ secrets. }}"` and each variable as `${{ toJSON(vars.) }}`. A -folded `>-` scalar keeps the source readable while producing a single-line value, as long as the JSON -content lines stay at the same indentation level: +Values under `secrets` are masked in the logs; values under `variables` are not. Build it in the calling workflow and +pass it through the `secrets:` blocks of both caller jobs so the whole blob is masked. Reference each secret directly +as `"${{ secrets. }}"` and each variable as `${{ toJSON(vars.) }}`. A folded `>-` scalar keeps the source +readable while producing a single-line value, as long as the JSON content lines stay at the same indentation level: ```yaml -jobs: - Process-PSModule: - uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v8 - secrets: - PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} - GitHubAppClientId: ${{ secrets.SHELLY_CLIENT_ID }} - GitHubAppPrivateKey: ${{ secrets.SHELLY_PRIVATE_KEY }} - TestData: >- - { "secrets": { "CONFLUENCE_API_TOKEN": "${{ secrets.CONFLUENCE_API_TOKEN }}" }, - "variables": { "CONFLUENCE_SITE": ${{ toJSON(vars.CONFLUENCE_SITE) }}, - "CONFLUENCE_USERNAME": ${{ toJSON(vars.CONFLUENCE_USERNAME) }}, - "CONFLUENCE_SPACE_KEY": ${{ toJSON(vars.CONFLUENCE_SPACE_KEY) }} } } +TestData: >- + { "secrets": { "CONFLUENCE_API_TOKEN": "${{ secrets.CONFLUENCE_API_TOKEN }}" }, + "variables": { "CONFLUENCE_SITE": ${{ toJSON(vars.CONFLUENCE_SITE) }}, + "CONFLUENCE_USERNAME": ${{ toJSON(vars.CONFLUENCE_USERNAME) }}, + "CONFLUENCE_SPACE_KEY": ${{ toJSON(vars.CONFLUENCE_SPACE_KEY) }} } } ``` Each entry becomes an environment variable in the test jobs, so the module's Pester tests read the diff --git a/docs/content/guides/github-app-authentication.md b/docs/content/guides/github-app-authentication.md index cf35933a..0de5d477 100644 --- a/docs/content/guides/github-app-authentication.md +++ b/docs/content/guides/github-app-authentication.md @@ -18,11 +18,18 @@ The reusable workflow declares two required secrets at its `workflow_call` bound | `GitHubAppPrivateKey` | The GitHub App private key passed to the token action. | The names are the reusable workflow contract, not a requirement for the caller's repository or organization secret -names. Map the caller's secrets explicitly: +names. Map the caller's secrets explicitly in both caller jobs: ```yaml jobs: - Process-PSModule: + Process-PSModule-Production: + uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v8 + secrets: + PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} + GitHubAppClientId: ${{ secrets.SHELLY_CLIENT_ID }} + GitHubAppPrivateKey: ${{ secrets.SHELLY_PRIVATE_KEY }} + + Process-PSModule-PullRequest: uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v8 secrets: PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} diff --git a/docs/content/reference/repository-standard.md b/docs/content/reference/repository-standard.md index 6348b69b..cf723b20 100644 --- a/docs/content/reference/repository-standard.md +++ b/docs/content/reference/repository-standard.md @@ -117,11 +117,19 @@ The module repository owns a caller workflow; the framework owns the reusable wo | Caller workflow | The module repository | `.github/workflows/Process-PSModule.yml` | | Reusable workflow | [`PSModule/Process-PSModule`](https://github.com/PSModule/Process-PSModule) | `.github/workflows/workflow.yml` | -The caller workflow declares the triggers, concurrency, and permissions for the module repository, and delegates the work: +The caller workflow declares the triggers, concurrency, and permissions for the module repository. Its production and +pull-request jobs use the same reusable workflow and explicit secret mapping: ```yaml jobs: - Process-PSModule: + Process-PSModule-Production: + uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@ # + secrets: + PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} + GitHubAppClientId: ${{ secrets.SHELLY_CLIENT_ID }} + GitHubAppPrivateKey: ${{ secrets.SHELLY_PRIVATE_KEY }} + + Process-PSModule-PullRequest: uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@ # secrets: PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} diff --git a/docs/content/specification/design.md b/docs/content/specification/design.md index 3a26f182..c9e3ceaf 100644 --- a/docs/content/specification/design.md +++ b/docs/content/specification/design.md @@ -19,10 +19,9 @@ documented in [Workflow inputs](../reference/workflow-inputs.md). ### Trigger admission -The [workflow trigger design](workflow-triggers/design.md) owns caller-level admission before processing: a retained -production queue and replaceable pull-request activity. The caller's workflow-level group covers the complete reusable -workflow call through its final enabled stage; the reusable workflow identifies closure and performs optional prerelease -cleanup. +The [workflow trigger design](workflow-triggers/design.md) owns caller admission before processing: a retained +production queue and replaceable pull-request activity. The caller jobs cover each complete reusable-workflow call +through its final enabled stage; the reusable workflow identifies closure and performs optional prerelease cleanup. ### Composed reusable workflows diff --git a/docs/content/specification/workflow-triggers/design.md b/docs/content/specification/workflow-triggers/design.md index 3271572a..65f6b846 100644 --- a/docs/content/specification/workflow-triggers/design.md +++ b/docs/content/specification/workflow-triggers/design.md @@ -1,12 +1,12 @@ --- title: Workflow triggers - Design -description: Use one caller-owned GitHub Actions concurrency group for retained production and replaceable pull-request work. +description: Use separate caller-owned GitHub Actions job policies for retained production and replaceable pull-request work. --- # Workflow triggers - Design -The caller workflow holds one concurrency slot for its complete call to `.github/workflows/workflow.yml`. It retains -non-pull-request work, replaces activity for an individual pull request, and lets the reusable workflow select its +The caller workflow uses two mutually exclusive calling jobs for `.github/workflows/workflow.yml`. One retains +non-pull-request work; the other replaces activity for an individual pull request. The reusable workflow selects its normal processing or close behavior from the incoming event. ## Specification @@ -15,30 +15,35 @@ normal processing or close behavior from the incoming event. ## Approach -Every caller uses this workflow-level configuration: +Every caller uses these job-level policies: ```yaml -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - queue: ${{ github.event_name == 'pull_request' && 'single' || 'max' }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} +jobs: + Process-PSModule-Production: + if: ${{ github.event_name != 'pull_request' }} + concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + queue: max + + Process-PSModule-PullRequest: + if: ${{ github.event_name == 'pull_request' }} + concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + queue: single + cancel-in-progress: true ``` -The group expression is a fallback, not concatenation. A pull-request event uses its number; every other event uses -its Git ref. The caller workflow name scopes the group from other workflow names in the same repository, so no -additional prefix is needed. - -The queue expression distinguishes the two policies: - -- Pull-request events use the one-pending replacement queue and cancel active work. -- All other events use the maximum retained queue and do not cancel active work. This includes `push`, - `workflow_dispatch`, and any scheduled event. +Each calling job uses the same permissions, reusable-workflow reference, and secret mapping. The caller workflow name +scopes both groups from other workflows in the same repository, so no additional prefix is needed. GitHub requires a +literal queue value and rejects `queue: max` with `cancel-in-progress: true`; separate jobs are therefore required to +apply both policies. ## Alternatives considered | Option | Trade-offs | Verdict | | --- | --- | --- | -| One conditional caller workflow-level group | Covers the complete reusable-workflow call with three expressions and preserves the required event behavior. | Chosen. | +| Two caller job-level policies | Each complete reusable-workflow call has a static, compatible queue and cancellation policy. | Chosen. | +| One conditional caller workflow-level group | GitHub requires a literal `queue` value and rejects `queue: max` with cancellation enabled. | Rejected. | | One conditional producer workflow-level group | A live experiment failed concurrent production admission before jobs began. | Rejected. | | One caller group with `queue: max` and no cancellation | Retains every event but does not converge pull-request activity or prioritize closure. | Rejected. | | Per-track router jobs | Separates closure from activity but adds nested reusable workflows, receipts, and coordination beyond the required caller setting. | Rejected. | @@ -48,12 +53,12 @@ The queue expression distinguishes the two policies: | Component | Responsibility | | --- | --- | -| Caller workflow | Subscribes to events and applies the concurrency group before invoking the reusable workflow. | +| Caller workflow | Subscribes to events and routes each event to the calling job with its concurrency policy. | | GitHub Actions | Retains non-pull-request work or cancels superseded pull-request work for the matching group. | | `workflow.yml` | Receives the original caller event and routes it through the existing processing or close path. | -The caller-level group covers all nested reusable jobs until the calling job completes. It therefore serializes planning, -version resolution, publication, and enabled teardown rather than only a short admission step. +Each caller job-level group covers all nested reusable jobs until the calling job completes. It therefore serializes +planning, version resolution, publication, and enabled teardown rather than only a short admission step. ## Data and contracts @@ -71,11 +76,11 @@ different group and proceeds independently. ## Reusable workflow compatibility -The caller group is deliberately unprefixed. The current reusable workflow uses +The caller groups are deliberately unprefixed. The current reusable workflow uses `Process-PSModule-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}`. The names differ, -which prevents a cancellation-enabled caller group from canceling its own caller job through the called workflow. +which prevents the cancellation-enabled caller job from canceling its own caller job through the called workflow. -Do not add `Process-PSModule-` to the caller group while that reusable group exists. The group names are +Do not add `Process-PSModule-` to either caller group while that reusable group exists. The group names are repository-local and case-insensitive. If a future reusable workflow removes its group, this caller configuration remains valid without modification. @@ -88,17 +93,13 @@ reusable workflow retains responsibility for credential handling and for evaluat Use a disposable, nonpublishing caller and producer. Verify three competing production pushes finish serially, a manual run waits behind production, rapid updates cancel activity for one pull request without affecting another, and -a close event cancels activity before the close path runs. - -Live GitHub Actions experiments validated the configuration with a distinct prefixed group in the reusable producer: -three production runs completed serially; a manual run waited behind a push and executed the production path; three -rapid pull-request activity runs were canceled; and the closure run completed its close job. A separate experiment -showed that placing the conditional group in the producer fails under concurrent production admission. +a close event cancels activity before the close path runs. Confirm the pull-request job uses `queue: single` and the +production job uses `queue: max`. ## Rollout and operability -Replace the caller's existing `cancel-in-progress: false` block with the documented configuration. No new input, -secret, dispatcher, or reusable-workflow version is required. Keep the group distinct from the reusable workflow's +Replace the caller's existing generic calling job with the documented production and pull-request jobs. No new input, +secret, dispatcher, or reusable-workflow version is required. Keep both groups distinct from the reusable workflow's prefixed group. GitHub retains one running and up to 100 pending executions for a `max` group. It does not guarantee commit-order diff --git a/docs/content/specification/workflow-triggers/spec.md b/docs/content/specification/workflow-triggers/spec.md index c36fe51f..48b46b9b 100644 --- a/docs/content/specification/workflow-triggers/spec.md +++ b/docs/content/specification/workflow-triggers/spec.md @@ -1,11 +1,11 @@ --- title: Workflow triggers - Spec -description: Retain production work while superseding obsolete pull-request activity with one caller concurrency group. +description: Retain production work while superseding obsolete pull-request activity with separate caller policies. --- # Workflow triggers - Spec -The caller workflow admits Process-PSModule work with one native GitHub Actions concurrency group. Default-branch +The caller workflow admits Process-PSModule work with separate native GitHub Actions policies. Default-branch production and manual releases are retained; activity for each pull request converges on its latest event; a close event supersedes that pull request's activity before the reusable workflow evaluates optional prerelease cleanup. @@ -41,21 +41,22 @@ ownership or deletion implementation. ## Functional requirements -### FR1 - Admit work through one caller policy {#fr1} +### FR1 - Admit work through caller policies {#fr1} -The caller workflow MUST define one workflow-level concurrency group for the complete reusable-workflow call. The -group MUST use the pull-request number when available and the Git ref otherwise. It MUST retain non-pull-request work -and replace pull-request work. The reusable workflow selects processing or close behavior from the original event. +The caller workflow MUST apply a retained policy to non-pull-request work and a superseding policy to pull-request +work. The retained policy MUST group work by Git ref without canceling it; the superseding policy MUST group work by +pull-request number and cancel obsolete activity. Each policy MUST cover its complete reusable-workflow call. The +reusable workflow selects processing or close behavior from the original event. #### Caller admission scenario ```gherkin Scenario: Call the workflow Given a caller subscribes to default-branch and pull-request events - And it declares the documented concurrency group + And it declares the documented concurrency policies When it calls Process-PSModule - Then its complete reusable-workflow call has one admission policy - And no dispatcher or per-track caller jobs are required + Then each complete reusable-workflow call has its required admission policy + And no dispatcher is required ``` ### FR2 - Retain and serialize production work {#fr2} @@ -120,8 +121,8 @@ remains visible as a canceled execution; it MUST NOT be reported as retained wor ### NFR2 - Keep lifecycle identities isolated {#nfr2} Concurrency identity MUST distinguish every pull request from all Git refs. A closed pull request uses its number, -not the default-branch ref associated with a merge. The caller group MUST differ from any reusable-workflow group to -avoid recursive cancellation. +not the default-branch ref associated with a merge. Caller groups MUST differ from any reusable-workflow group to avoid +recursive cancellation. ## Acceptance criteria From da1087ec0dbdc8b0cd857bfccd1500567eab69c7 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 5 Sep 2026 00:54:00 +0200 Subject: [PATCH 09/13] Exercise caller concurrency policies in workflow tests Split each internal caller into retained production and replaceable pull-request jobs while keeping artifact verification reachable after the skipped sibling job. Co-authored-by: Copilot <223556698+Copilot@users.noreply.github.com> --- .github/workflows/Workflow-Test-Default.yml | 57 +++++++++++++++---- .../workflows/Workflow-Test-WithManifest.yml | 57 +++++++++++++++---- 2 files changed, 94 insertions(+), 20 deletions(-) diff --git a/.github/workflows/Workflow-Test-Default.yml b/.github/workflows/Workflow-Test-Default.yml index 2bbae66e..f04c4db3 100644 --- a/.github/workflows/Workflow-Test-Default.yml +++ b/.github/workflows/Workflow-Test-Default.yml @@ -30,18 +30,49 @@ on: schedule: - cron: '0 0 * * *' -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: false - -permissions: - contents: read - pages: write - id-token: write +permissions: {} jobs: WorkflowTestDefault: - if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} + if: ${{ github.event_name != 'pull_request' }} + concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + queue: max + permissions: + contents: read + pages: write + id-token: write + uses: ./.github/workflows/workflow.yml + secrets: + PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} + GitHubAppClientId: ${{ secrets.SHELLY_CLIENT_ID }} + GitHubAppPrivateKey: ${{ secrets.SHELLY_PRIVATE_KEY }} + TestData: >- + { + "secrets": { + "PSMODULE_TEST_SINGLELINE_SECRET": "${{ secrets.PSMODULE_TEST_SINGLELINE_SECRET }}" + }, + "variables": { + "PSMODULE_TEST_VARIABLE": ${{ toJSON(vars.PSMODULE_TEST_VARIABLE) }} + } + } + with: + WorkingDirectory: tests/srcTestRepo + ImportantFilePatterns: | + ^tests/srcTestRepo/ + ^\.github/actions/ + ^\.github/workflows/(?!Release\.yml$|Linter\.yml$) + + WorkflowTestDefaultPullRequest: + if: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }} + concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + queue: single + cancel-in-progress: true + permissions: + contents: read + pages: write + id-token: write uses: ./.github/workflows/workflow.yml secrets: PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} @@ -64,11 +95,17 @@ jobs: ^\.github/workflows/(?!Release\.yml$|Linter\.yml$) VerifyRootFunctionsIndexDefault: - if: github.event.action != 'closed' + if: >- + ${{ always() && github.event.action != 'closed' && + (needs.WorkflowTestDefault.result == 'success' || + needs.WorkflowTestDefaultPullRequest.result == 'success') }} name: Verify root Functions index [Default] runs-on: ubuntu-latest needs: - WorkflowTestDefault + - WorkflowTestDefaultPullRequest + permissions: + contents: read steps: - name: Checkout repo uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/Workflow-Test-WithManifest.yml b/.github/workflows/Workflow-Test-WithManifest.yml index 83829813..41b59181 100644 --- a/.github/workflows/Workflow-Test-WithManifest.yml +++ b/.github/workflows/Workflow-Test-WithManifest.yml @@ -30,18 +30,49 @@ on: schedule: - cron: '0 0 * * *' -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: false - -permissions: - contents: read - pages: write - id-token: write +permissions: {} jobs: WorkflowTestWithManifest: - if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} + if: ${{ github.event_name != 'pull_request' }} + concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + queue: max + permissions: + contents: read + pages: write + id-token: write + uses: ./.github/workflows/workflow.yml + secrets: + PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} + GitHubAppClientId: ${{ secrets.SHELLY_CLIENT_ID }} + GitHubAppPrivateKey: ${{ secrets.SHELLY_PRIVATE_KEY }} + TestData: >- + { + "secrets": { + "PSMODULE_TEST_SINGLELINE_SECRET": "${{ secrets.PSMODULE_TEST_SINGLELINE_SECRET }}" + }, + "variables": { + "PSMODULE_TEST_VARIABLE": ${{ toJSON(vars.PSMODULE_TEST_VARIABLE) }} + } + } + with: + WorkingDirectory: tests/srcWithManifestTestRepo + ImportantFilePatterns: | + ^tests/srcWithManifestTestRepo/ + ^\.github/actions/ + ^\.github/workflows/(?!Release\.yml$|Linter\.yml$) + + WorkflowTestWithManifestPullRequest: + if: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }} + concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + queue: single + cancel-in-progress: true + permissions: + contents: read + pages: write + id-token: write uses: ./.github/workflows/workflow.yml secrets: PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} @@ -64,11 +95,17 @@ jobs: ^\.github/workflows/(?!Release\.yml$|Linter\.yml$) VerifyRootFunctionsIndexWithManifest: - if: github.event.action != 'closed' + if: >- + ${{ always() && github.event.action != 'closed' && + (needs.WorkflowTestWithManifest.result == 'success' || + needs.WorkflowTestWithManifestPullRequest.result == 'success') }} name: Verify root Functions index [WithManifest] runs-on: ubuntu-latest needs: - WorkflowTestWithManifest + - WorkflowTestWithManifestPullRequest + permissions: + contents: read steps: - name: Checkout repo uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 From 153529e0b93d688d539fb81bc16323c1f5fdd9e4 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 5 Sep 2026 00:54:19 +0200 Subject: [PATCH 10/13] Scope repository setup permissions to caller jobs Co-authored-by: Copilot <223556698+Copilot@users.noreply.github.com> --- docs/content/get-started/repository-setup.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/content/get-started/repository-setup.md b/docs/content/get-started/repository-setup.md index fa72496a..6a81c7e0 100644 --- a/docs/content/get-started/repository-setup.md +++ b/docs/content/get-started/repository-setup.md @@ -52,8 +52,6 @@ on: - labeled - unlabeled -permissions: {} - jobs: Process-PSModule-Production: if: ${{ github.event_name != 'pull_request' }} From 0e34e22d8804fb061df2c644aa2099a9b4868ec0 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 5 Sep 2026 01:25:39 +0200 Subject: [PATCH 11/13] Align module setup guidance with repository standard Document the baseline, required workflow credentials, supported site configuration, and exact caller contract consistently across setup and reference guides. Co-authored-by: Copilot <223556698+Copilot@users.noreply.github.com> --- docs/content/get-started/index.md | 14 +++--- docs/content/get-started/repository-setup.md | 50 +++++++++++++------ docs/content/guides/calling-the-workflow.md | 31 ++---------- .../guides/github-app-authentication.md | 13 ++--- .../content/guides/structuring-your-module.md | 17 +++++-- docs/content/index.md | 2 +- docs/content/reference/repository-standard.md | 44 ++++++++-------- docs/content/reference/workflow-inputs.md | 5 +- docs/content/specification/design.md | 2 +- 9 files changed, 95 insertions(+), 83 deletions(-) diff --git a/docs/content/get-started/index.md b/docs/content/get-started/index.md index 72cc182b..18a00b1d 100644 --- a/docs/content/get-started/index.md +++ b/docs/content/get-started/index.md @@ -11,18 +11,20 @@ Start new modules from the PSModule template repository: ## Quickstart 1. [Create a new repository from the template](https://github.com/new?template_name=Template-PSModule&template_owner=PSModule&description=Add%20a%20description%20(required)&name=%3CModule%20name%3E). -2. [Configure the repository](repository-setup.md) — GitHub Pages, `PSGALLERY_API_KEY`, and the caller workflow. -3. Replace placeholder metadata and remove scaffold sample files. +2. Complete the [module repository baseline](repository-setup.md#1-complete-the-module-repository-baseline): replace + template tokens, remove scaffolding, set `Type: Module`, and confirm required files. +3. [Configure the repository](repository-setup.md) — GitHub Pages, workflow secrets, the caller workflow, settings, and + Zensical. 4. Add your first public command and tests. -5. Validate `.github/PSModule.yml` defaults for your module. -6. [Open a draft pull request](your-first-release.md), then release from its resulting important default-branch push. +5. [Open a draft pull request](your-first-release.md), then release from its resulting important default-branch push. If the module needs several interdependent commands before it is usable at all, see [Module bootstrap](module-bootstrap.md) instead of shipping them as one command per step. ## Expected outcomes -- The repository follows the [expected structure](../guides/structuring-your-module.md). +- The repository follows the [repository standard](../reference/repository-standard.md) and + [expected structure](../guides/structuring-your-module.md). - The module can be built and tested in CI. - The release strategy is ready when functionality is implemented. @@ -30,7 +32,7 @@ If the module needs several interdependent commands before it is usable at all, | Page | Description | | --- | --- | -| [Repository setup](repository-setup.md) | GitHub Pages, `PSGALLERY_API_KEY`, permissions, and the caller workflow. | +| [Repository setup](repository-setup.md) | Repository baseline, GitHub Pages, workflow secrets, and the caller workflow. | | [Your first release](your-first-release.md) | The pull request flow, version labels, and the resulting default-branch release. | | [Module bootstrap](module-bootstrap.md) | Getting a brand-new module to its first release with an integration branch. | diff --git a/docs/content/get-started/repository-setup.md b/docs/content/get-started/repository-setup.md index 6a81c7e0..8aede537 100644 --- a/docs/content/get-started/repository-setup.md +++ b/docs/content/get-started/repository-setup.md @@ -6,9 +6,23 @@ description: Configure GitHub Pages, `PSGALLERY_API_KEY`, permissions, and the c # Repository setup Do this once per module repository, after creating it from -[Template-PSModule](https://github.com/PSModule/Template-PSModule). +[Template-PSModule](https://github.com/PSModule/Template-PSModule). The template supplies the initial repository +files and framework wiring; this guide verifies the standard baseline and configures its external services. -## 1. Enable GitHub Pages +## 1. Complete the module repository baseline + +Before configuring the pipeline: + +1. Replace every template token, including the README and `.github/zensical.toml`. +2. Remove scaffold functions, tests, and examples that do not belong to the module. +3. Set the repository description and its `Type` custom property to `Module`; retain `main` as the default branch. +4. Confirm the required community, governance, agent, dependency-update, and workflow files are present. +5. Confirm the README follows the module start-page requirements, including `Install-PSResource` installation guidance. +6. Keep `.github/PSModule.yml` limited to settings that override the framework defaults. + +[Repository standard](../reference/repository-standard.md) defines the required files, metadata, and README shape. + +## 2. Enable GitHub Pages Enable GitHub Pages in the repository settings and set it to deploy from **GitHub Actions**. @@ -18,18 +32,23 @@ This creates an environment called `github-pages` that GitHub deploys the docume Remove the branch protection on main -## 2. Create `PSGALLERY_API_KEY` +## 3. Configure workflow secrets + +Create these repository or organization Actions secrets: -1. [Create an API key on the PowerShell Gallery](https://www.powershellgallery.com/account/apikeys). Give it permission - to manage the module you are working on. -2. Create a repository or organization secret called `PSGALLERY_API_KEY` and set the API key as its value. +| Secret | Purpose | +| --- | --- | +| `PSGALLERY_API_KEY` | An [API key](https://www.powershellgallery.com/account/apikeys) authorized to manage the module on the PowerShell Gallery. | +| `SHELLY_CLIENT_ID` | The GitHub App client ID that the caller maps to `GitHubAppClientId`. | +| `SHELLY_PRIVATE_KEY` | The GitHub App private key that the caller maps to `GitHubAppPrivateKey`. | -If you plan to create many modules, use a glob pattern for the API key permissions in the PowerShell Gallery and store -`PSGALLERY_API_KEY` on the organization instead of on each repository. +Use a glob pattern for PowerShell Gallery API-key permissions and store `PSGALLERY_API_KEY` at the organization level +when several modules share it. For Dependabot pull requests, add all three secrets to the Dependabot secret store. +[GitHub App authentication](../guides/github-app-authentication.md) defines the App permissions and token boundaries. -## 3. Add the caller workflow +## 4. Verify the caller workflow -Create `.github/workflows/Process-PSModule.yml` in the module repository: +The template supplies `.github/workflows/Process-PSModule.yml`. Replace the caller with this standard form: ```yaml name: Process-PSModule @@ -96,9 +115,10 @@ cancels obsolete activity and uses the pull-request number for every action, inc literal queue value and does not permit cancellation with `queue: max`, so the two jobs use separate compatible policies. Keep both groups distinct from the reusable workflow's prefixed group. -## 4. Add the settings file +## 5. Configure the settings file -Create `.github/PSModule.yml`. An empty file is valid — every setting has a default: +The template supplies `.github/PSModule.yml`. Keep only the overrides the module needs; an empty file is valid when a +hand-built repository needs no overrides: ```yaml Name: null @@ -107,10 +127,10 @@ Name: null See [Settings](../reference/settings.md) for the full contract and [Configuring the pipeline](../guides/configuring-the-pipeline.md) for worked examples. -## 5. Configure the documentation site +## 6. Configure the documentation site -Process-PSModule builds documentation with [Zensical](https://zensical.org/) from `.github/zensical.toml`. The template -ships a working file; update the site name and repository links to match the module. +The template builds documentation with [Zensical](https://zensical.org/) from `.github/zensical.toml`. Replace each +template token with the module's name, owner, and repository URL. ## Next diff --git a/docs/content/guides/calling-the-workflow.md b/docs/content/guides/calling-the-workflow.md index 70194d7e..75524943 100644 --- a/docs/content/guides/calling-the-workflow.md +++ b/docs/content/guides/calling-the-workflow.md @@ -5,8 +5,9 @@ description: How to call the Process-PSModule reusable workflow — the caller w # Calling the workflow -To use the workflow, create a new file in the `.github/workflows` directory of the module repository and add the following content. -For documentation site generation, use `zensical.toml` as the active site contract. +Template-PSModule supplies a starter `.github/workflows/Process-PSModule.yml`. Replace it with this standard +template; repositories created without the template create the file with this content. The template's documentation +contract uses `.github/zensical.toml`. For the exact inputs, secrets, and permissions the reusable workflow declares, see [Workflow inputs](../reference/workflow-inputs.md). @@ -35,8 +36,6 @@ on: - labeled - unlabeled -permissions: {} - jobs: Process-PSModule-Production: if: ${{ github.event_name != 'pull_request' }} @@ -230,28 +229,8 @@ settings file: ImportantFilePatterns: [] ``` -You can also pass patterns via the workflow input: - -```yaml -jobs: - Process: - uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v8 - with: - ImportantFilePatterns: | - ^src/ - ^README\.md$ - ^examples/ -``` - -To disable triggering via the workflow input, pass an explicit empty string: - -```yaml -jobs: - process: - uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v8 - with: - ImportantFilePatterns: '' -``` +The standard caller does not add `with:` overrides. Configure +`ImportantFilePatterns` in `.github/PSModule.yml`. Note that omitting the `ImportantFilePatterns` key entirely causes the workflow's default patterns (`^src/` and `^README\.md$`) to be used. The settings file takes priority over the workflow input, so set diff --git a/docs/content/guides/github-app-authentication.md b/docs/content/guides/github-app-authentication.md index 0de5d477..a16748a8 100644 --- a/docs/content/guides/github-app-authentication.md +++ b/docs/content/guides/github-app-authentication.md @@ -10,10 +10,11 @@ installation tokens. These workflows do not use `github.token` as a fallback for ## Caller secret contract -The reusable workflow declares two required secrets at its `workflow_call` boundary: +The reusable workflow declares three required secrets at its `workflow_call` boundary: | Name | Purpose | | --- | --- | +| `PSGALLERY_API_KEY` | The API key that publishes the module to the PowerShell Gallery. | | `GitHubAppClientId` | The GitHub App client ID passed to the token action. | | `GitHubAppPrivateKey` | The GitHub App private key passed to the token action. | @@ -37,13 +38,13 @@ jobs: GitHubAppPrivateKey: ${{ secrets.SHELLY_PRIVATE_KEY }} ``` -The root reusable workflow forwards these two values to the Plan, Build-Module, and Publish-Module reusable jobs. -Do not use `secrets: inherit` as a substitute for this mapping. +The root reusable workflow forwards the GitHub App credentials to the Plan, Build-Module, and Publish-Module reusable +jobs, and passes the Gallery API key to publication. Do not use `secrets: inherit` as a substitute for this mapping. Dependabot-triggered workflows cannot read regular Actions secrets. To run Process-PSModule on Dependabot pull -requests, create `SHELLY_CLIENT_ID` and `SHELLY_PRIVATE_KEY` as Dependabot secrets in addition to Actions secrets. -This is a deliberate trust boundary: review the App's installation scope and every dependency update carefully, -because the workflow can mint a Shelly token before human review. +requests, create `PSGALLERY_API_KEY`, `SHELLY_CLIENT_ID`, and `SHELLY_PRIVATE_KEY` as Dependabot secrets in addition +to Actions secrets. This is a deliberate trust boundary: review the App's installation scope and every dependency +update carefully, because the workflow can mint a Shelly token before human review. ## GitHub App installation permissions diff --git a/docs/content/guides/structuring-your-module.md b/docs/content/guides/structuring-your-module.md index c4f36cf3..b796920e 100644 --- a/docs/content/guides/structuring-your-module.md +++ b/docs/content/guides/structuring-your-module.md @@ -27,7 +27,7 @@ The goal is a stable repository anatomy so both humans and automation know exact ```plaintext / -├── .github/ # Workflow config, doc/site templates, automation policy +├── .github/ # Workflow config, site template, and automation policy │ ├── linters/ # Rule sets applied by shared lint steps │ │ ├── .markdown-lint.yml # Markdown rules enforced via super-linter │ │ ├── .powershell-psscriptanalyzer.psd1 # Analyzer profile for test jobs @@ -36,13 +36,21 @@ The goal is a stable repository anatomy so both humans and automation know exact │ │ └── Process-PSModule.yml # Consumer hook into this workflow bundle │ ├── CODEOWNERS # Default reviewers enforced by Process-PSModule checks │ ├── dependabot.yml # Dependency update cadence handled by GitHub -│ ├── zensical.toml # Site config consumed during site builds │ ├── PSModule.yml # Settings parsed to drive matrices -│ └── release.yml # Release automation template invoked on publish +│ ├── pull_request_template.md # PR description structure +│ ├── release.yml # Release automation template invoked on publish +│ └── zensical.toml # Site config consumed during site builds +├── AGENTS.md # Agent entry point +├── CLAUDE.md # Claude Code route to AGENTS.md +├── CODE_OF_CONDUCT.md # Community conduct expectations +├── CONTRIBUTING.md # Repository contribution guidance ├── examples/ # Samples referenced in generated documentation │ └── General.ps1 # Example script processed by Process-PSModule ├── icon/ # Icon assets linked from manifest and documentation │ └── icon.png # Default module icon (PNG format) +├── LICENSE # Reuse terms +├── README.md # Module landing page rendered on GitHub and docs +├── SECURITY.md # Security reporting policy ├── src/ # Module source, see "Module source code structure" below ├── tests/ # Pester suites; the Simple layout is shown │ ├── AfterAll.ps1 (optional) # Cleanup script for ModuleLocal runs @@ -50,8 +58,7 @@ The goal is a stable repository anatomy so both humans and automation know exact │ └── .Tests.ps1 # Simple: one root-level module suite ├── .gitattributes # Normalizes line endings across platforms ├── .gitignore # Excludes build artifacts from source control -├── LICENSE # License text surfaced in manifest metadata -└── README.md # Repository overview rendered on GitHub and docs landing +└── SUPPORT.md # Support expectations ``` The tree shows the [Simple PowerShell test profile](https://msx.no/docs/Coding-Standards/PowerShell/Testing/#simple), not an exclusive test-file shape. Standard keeps one root-level `tests/.Tests.ps1` file per public function group. Advanced uses recursively discovered subdirectories, and layouts may mix across directories. Process-PSModule defines the exact [per-directory precedence and sibling suppression](writing-module-tests.md#test-discovery). diff --git a/docs/content/index.md b/docs/content/index.md index 02cc155f..c25341f1 100644 --- a/docs/content/index.md +++ b/docs/content/index.md @@ -26,7 +26,7 @@ New to Process-PSModule? Work through these in order. | Page | Description | | --- | --- | | [Get started](get-started/index.md) | Create a module repository from the template and get the pipeline running. | -| [Repository setup](get-started/repository-setup.md) | Configure GitHub Pages, `PSGALLERY_API_KEY`, permissions, and the caller workflow. | +| [Repository setup](get-started/repository-setup.md) | Repository baseline, GitHub Pages, workflow secrets, and the caller workflow. | | [Your first release](get-started/your-first-release.md) | The pull request flow, version labels, and the resulting default-branch release. | ## Guides diff --git a/docs/content/reference/repository-standard.md b/docs/content/reference/repository-standard.md index cf723b20..8f264cc5 100644 --- a/docs/content/reference/repository-standard.md +++ b/docs/content/reference/repository-standard.md @@ -87,6 +87,7 @@ Module repositories use the PSModule framework layout: | `AGENTS.md` | Agent onboarding entry point. Points agents to the canonical guidance at `https://psmodule.io/docs/`. | | `CLAUDE.md` | Claude Code entry point. Imports `AGENTS.md` so Claude reads the same instructions. | | `.github/PSModule.yml` | Module workflow configuration overrides. | +| `.github/zensical.toml` | Documentation site configuration consumed by the Process-PSModule site build. | | `.github/workflows/Process-PSModule.yml` | Caller workflow that runs the module's CI/CD by calling the shared Process-PSModule workflow. | | `.github/release.yml` | Release-note and changelog categorization for GitHub releases. | | `.github/linters/` | Linter configuration used by the framework's linting stage, including `.markdown-lint.yml` and `.powershell-psscriptanalyzer.psd1`. | @@ -117,27 +118,25 @@ The module repository owns a caller workflow; the framework owns the reusable wo | Caller workflow | The module repository | `.github/workflows/Process-PSModule.yml` | | Reusable workflow | [`PSModule/Process-PSModule`](https://github.com/PSModule/Process-PSModule) | `.github/workflows/workflow.yml` | -The caller workflow declares the triggers, concurrency, and permissions for the module repository. Its production and -pull-request jobs use the same reusable workflow and explicit secret mapping: +The caller workflow declares the triggers, concurrency, permissions, and explicit secret mapping for the module +repository. Name it `Process-PSModule.yml`, matching +[`PSModule/Template-PSModule`](https://github.com/PSModule/Template-PSModule) and existing module repositories. +`workflow.yml` is the reusable workflow's own filename inside `PSModule/Process-PSModule` and belongs only in the +`uses:` reference. -```yaml -jobs: - Process-PSModule-Production: - uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@ # - secrets: - PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} - GitHubAppClientId: ${{ secrets.SHELLY_CLIENT_ID }} - GitHubAppPrivateKey: ${{ secrets.SHELLY_PRIVATE_KEY }} - - Process-PSModule-PullRequest: - uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@ # - secrets: - PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} - GitHubAppClientId: ${{ secrets.SHELLY_CLIENT_ID }} - GitHubAppPrivateKey: ${{ secrets.SHELLY_PRIVATE_KEY }} -``` +The caller uses these mutually exclusive jobs: + +| Job | Events | Concurrency policy | +| --- | --- | --- | +| `Process-PSModule-Production` | Non-pull-request events | Group by Git ref with `queue: max`; do not cancel runs. | +| `Process-PSModule-PullRequest` | Pull-request events | Group by pull-request number with `queue: single`; cancel obsolete runs. | -Name the caller file `Process-PSModule.yml`, matching [`PSModule/Template-PSModule`](https://github.com/PSModule/Template-PSModule) and every existing module repository. `workflow.yml` is the reusable workflow's own filename inside `PSModule/Process-PSModule` and belongs only in the `uses:` reference. Pin the reference to a commit SHA with the version tag in a trailing comment so Dependabot can update it. +Both jobs use the same reusable-workflow reference, required job-level permissions, and explicit `PSGALLERY_API_KEY`, +`GitHubAppClientId`, and `GitHubAppPrivateKey` mapping. The exact +[caller template](../guides/calling-the-workflow.md) is part of this standard. Use the controlled +`PSModule/Process-PSModule` `@v8` major reference; pin external Actions dependencies to a commit SHA with the version +tag in a trailing comment so Dependabot can update them. Permissions are declared only on the caller jobs, not at the +workflow root. ## Required common files @@ -165,6 +164,7 @@ Required baseline files for module repositories: | `.github/CODEOWNERS` | Review routing for source, docs, and GitHub workflow files. | | `.github/pull_request_template.md` | Scaffolds pull requests in the MSX PR Format (PR Manager) style — an icon + change-type + user-facing-outcome title, user-facing description sections, an optional technical-details block, and a related-issues block. | | `.github/PSModule.yml` | Module workflow defaults and overrides. | +| `.github/zensical.toml` | Documentation site configuration consumed by the Process-PSModule site build. | | `.gitattributes` | Normalizes line endings and declares text/binary handling so the module can be developed and built consistently on Linux, macOS, and Windows. | | `.gitignore` | Ignores files that must never be committed, tailored to the PowerShell-module ecosystem: operating-system files, editor and developer-tooling files, PowerShell and Pester test-harness artifacts, and all local build outputs and files created during build and test. | @@ -201,7 +201,11 @@ For PSModule module repositories, the requirements are: Every module repository must include `.github/dependabot.yml`. Dependabot is part of the repository supply-chain control, not an optional convenience. -Configure the `github-actions` ecosystem. It keeps the pinned actions current, including the pinned `PSModule/Process-PSModule` reference in the [caller workflow](#caller-workflow-and-reusable-workflow). This is what [`PSModule/Template-PSModule`](https://github.com/PSModule/Template-PSModule) ships, and it is the default for new repositories: +Configure the `github-actions` ecosystem. It keeps external action SHA pins current. The +`PSModule/Process-PSModule` `@v8` reference in the [caller workflow](#caller-workflow-and-reusable-workflow) is a +controlled floating major that advances through Process-PSModule release automation. This is what +[`PSModule/Template-PSModule`](https://github.com/PSModule/Template-PSModule) ships, and it is the default for new +repositories: ```yaml version: 2 diff --git a/docs/content/reference/workflow-inputs.md b/docs/content/reference/workflow-inputs.md index a7c0d932..c3bfdd6a 100644 --- a/docs/content/reference/workflow-inputs.md +++ b/docs/content/reference/workflow-inputs.md @@ -35,10 +35,9 @@ credentials that are exposed. `secrets: inherit` is intentionally not required. See [passing test data](../guides/calling-the-workflow.md#passing-test-data) for how to build the `TestData` value. -## Workflow `github.token` permissions +## Caller-job `github.token` permissions -The following permissions are needed by the caller workflow's default `github.token` for checkout and GitHub Pages -deployment: +Each caller job needs the following permissions for checkout and GitHub Pages deployment: ```yaml permissions: diff --git a/docs/content/specification/design.md b/docs/content/specification/design.md index c9e3ceaf..1318233a 100644 --- a/docs/content/specification/design.md +++ b/docs/content/specification/design.md @@ -14,7 +14,7 @@ The behaviour in the [spec](spec.md) is delivered by a **single reusable GitHub The reusable workflow accepts a caller workflow and minimal caller configuration: a `pull_request`-triggered job for CI and prereleases plus a default-branch `push` trigger for stable publication. The caller calls `workflow.yml` and passes the required secrets. The full caller template is in -[Repository setup](../get-started/repository-setup.md#3-add-the-caller-workflow), and the interface it targets is +[Repository setup](../get-started/repository-setup.md#4-verify-the-caller-workflow), and the interface it targets is documented in [Workflow inputs](../reference/workflow-inputs.md). ### Trigger admission From a157acf4b6795df1617149f680ca8f937c5431ee Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 5 Sep 2026 01:25:49 +0200 Subject: [PATCH 12/13] Constrain documentation migration guidance to supported builds Keep consumer documentation changes within the current site staging contract and surface unsupported authored-content migrations as framework gaps. Co-authored-by: Copilot <223556698+Copilot@users.noreply.github.com> --- .../skills/psmodule-v8-upgrade/SKILL.md | 61 ++---- .../psmodule-zensical-migration/SKILL.md | 179 ++++-------------- 2 files changed, 56 insertions(+), 184 deletions(-) diff --git a/.github/plugin/psmodule/skills/psmodule-v8-upgrade/SKILL.md b/.github/plugin/psmodule/skills/psmodule-v8-upgrade/SKILL.md index a5bb8ee1..646cf4f9 100644 --- a/.github/plugin/psmodule/skills/psmodule-v8-upgrade/SKILL.md +++ b/.github/plugin/psmodule/skills/psmodule-v8-upgrade/SKILL.md @@ -40,10 +40,9 @@ what is present before deciding what to migrate: workflow extensions. Keep them in separate files and validate them in place. When documentation is absent, do not invent a site as part of the caller -upgrade unless the requested scope explicitly includes documentation migration. -When a legacy MkDocs configuration exists and documentation migration is in -scope, migrate its content and design deliberately to Zensical, then remove -the obsolete configuration only after the generated site validates. +upgrade. When a legacy MkDocs configuration exists, preserve it during the +caller upgrade. A documentation migration that needs consumer-authored content +or theme assets requires framework staging support before it can be delivered. ## Template-PSModule baseline @@ -125,20 +124,10 @@ Treat files and directories as follows: Preserve the consumer's local onboarding contract while updating stale links. If the template revision and the consumer's existing layout disagree, record -the difference and migrate only the requested integration surface. In -particular, the current template uses `.github/zensical.toml`, while this v8 -upgrade request uses `docs/zensical.toml`, `docs/content/`, and -`docs/overrides/` (including `docs/overrides/assets/`) as its documentation -contract; a separate `docs/assets/` directory is optional. When that requested -documentation migration is in scope, move the template's Zensical settings -and custom assets into the `docs/` contract rather than maintaining both -configurations. When it is not in scope, preserve the consumer's existing -working configuration and report the difference. - -For a documentation-only MkDocs migration, use the dedicated -[`psmodule-zensical-migration`](../psmodule-zensical-migration/SKILL.md) skill -so content, theme, assets, and link validation are handled independently from -the caller workflow upgrade. +the difference and migrate only the requested integration surface. The +template's `.github/zensical.toml` is the supported Process-PSModule consumer +configuration. Do not move its site configuration, content, or theme assets +into `docs/` as part of a v8 upgrade. ## Caller workflow contract @@ -165,8 +154,6 @@ on: - labeled - unlabeled -permissions: {} - jobs: Process-PSModule-Production: if: ${{ github.event_name != 'pull_request' }} @@ -241,24 +228,12 @@ uses. ## Documentation integration -Treat `docs/zensical.toml` as authoritative. Do not create or maintain -`mkdocs.yml`, introduce MkDocs configuration, or replace the consumer's -documentation design with a parallel theme. - -Compare the consumer's configuration with the Process-PSModule template and -preserve or migrate these surfaces deliberately: - -- `docs/zensical.toml`, including `docs_dir` and navigation. -- `docs/content/` as the documentation source directory. -- `docs/overrides/` and custom templates. -- `docs/overrides/assets/stylesheets/navigation.css`. -- `docs/overrides/assets/`, any optional `docs/assets/`, logo, favicon, palette, - fonts, and custom JavaScript/CSS. -- Existing navigation labels, page paths, and custom theme behavior. - -Do not delete existing custom CSS/assets merely because the default theme also -provides an equivalent feature. Resolve duplicate configuration in favor of -the existing consumer source of truth, then validate the generated site. +The v8 upgrade does not migrate documentation. Preserve the consumer's +existing documentation source, custom assets, and active site configuration. +For the Process-PSModule site build, keep the template's +`.github/zensical.toml` configuration. Do not create `docs/zensical.toml` or +move documentation content or theme assets into `docs/`; the module site build +does not stage that contract. ## Pester v6 migration @@ -310,15 +285,7 @@ when a targeted check reveals a broader dependency: equivalent when available. 3. Run the Pester v6 test suites with the repository's declared configuration. 4. Run the repository's existing lint and test commands. -5. When documentation exists, run: - - ```powershell - Push-Location docs - zensical build --clean - Pop-Location - ``` - -6. Review the diff for accidental workflow permissions, trigger changes, +5. Review the diff for accidental workflow permissions, trigger changes, secret exposure, fixture removal, generated files, or unrelated refactoring. Report commands and outcomes, including blocked checks and why they were diff --git a/.github/plugin/psmodule/skills/psmodule-zensical-migration/SKILL.md b/.github/plugin/psmodule/skills/psmodule-zensical-migration/SKILL.md index b6e19fef..3d5f027f 100644 --- a/.github/plugin/psmodule/skills/psmodule-zensical-migration/SKILL.md +++ b/.github/plugin/psmodule/skills/psmodule-zensical-migration/SKILL.md @@ -1,159 +1,64 @@ --- name: psmodule-zensical-migration -description: Migrate a PSModule repository from MkDocs to Zensical using the Process-PSModule documentation design as the default while preserving content, navigation, assets, and repository-specific behavior. +description: Assess a PSModule repository's documentation migration to Zensical without introducing a site layout the Process-PSModule pipeline cannot publish. --- -# Migrate PSModule documentation from MkDocs to Zensical +# Assess a PSModule Zensical migration -Use this skill when a PSModule repository has a legacy MkDocs configuration, -usually `.github/mkdocs.yml`, or needs its documentation site aligned with the -Process-PSModule Zensical design. Keep the migration limited to documentation -integration. Do not rewrite module code or tests unless a documentation build -requires a directly related fix. +Use this skill when a PSModule repository has a legacy MkDocs configuration or +needs its documentation site aligned with Zensical. Keep the assessment limited +to documentation integration. Do not rewrite module code or tests unless a +documentation build requires a directly related fix. -## Source-of-truth design +## Current Process-PSModule boundary -Use the current -[`Process-PSModule` `docs/zensical.toml`](https://github.com/PSModule/Process-PSModule/blob/main/docs/zensical.toml) -as the default style and configuration baseline. Reuse its behavior rather than -inventing a second theme: +The module site pipeline stages generated function documentation, `README.md`, +the module icon, and `.github/zensical.toml` into `outputs/site`. It does not +stage consumer-authored `docs/content/`, `docs/overrides/`, or their assets. +Consequently, a consumer configuration that sets `docs_dir = "content"` or +`custom_dir = "overrides"` cannot be published through the current framework. -- `docs_dir = "content"` with content under `docs/content/`. -- `docs/overrides/` as the custom theme directory. -- `docs/overrides/assets/stylesheets/navigation.css` for navigation styling. -- `docs/overrides/assets/` and any repository-owned `docs/assets/` directory - when present as the asset source of truth. -- Mona Sans text and Source Code Pro code fonts. -- Material-style GitHub/link icons, light/dark/system palette toggles, - black/slate/light-blue colors, and the established navigation features. -- Instant navigation, prefetch/preview/progress, tabs, tracking, top navigation, - search, code copy, tooltips, table of contents, and footer behavior. -- TOC, attribute lists, abbreviations, admonitions, definition lists, - footnotes, tables, HTML-in-Markdown, details, superfences, task lists, and - snippets extensions. -- Mermaid fenced blocks and the shared abbreviations snippet. -- Existing social links, consent configuration, site metadata, edit URI, and - custom tablesort JavaScript where those features apply. +Do not move a module consumer from `.github/zensical.toml` to +`docs/zensical.toml`, or prescribe the Process-PSModule repository's +`docs/content/` design, until the framework stages those paths. -Customize only repository identity and content-specific values such as -`site_name`, `site_url`, `repo_name`, `repo_url`, `edit_uri`, copyright, social -links, and `nav`. Do not remove a default feature merely because the old -MkDocs site did not use it. +## Assess before changing -## Inspect before changing +1. Read local guidance, the current documentation build command, and the + Process-PSModule caller workflow. +2. Inventory `.github/mkdocs.yml`, `mkdocs.yml`, `.github/zensical.toml`, and + any existing documentation sources, templates, media, and assets. +3. Identify whether documentation is published by Process-PSModule or by a + separate repository-owned workflow. +4. Preserve module source, generated help, custom assets, navigation, and + repository-owned workflows unless the requested migration requires them. -Inventory the existing repository and record: +## Supported consumer configuration -1. Local guidance, branch state, and documentation build commands. -2. `.github/mkdocs.yml`, any `mkdocs.yml`, and any existing Zensical config. -3. The Markdown content root, includes/snippets, templates, media, and assets. -4. `theme`, `theme.custom_dir`, `extra_css`, `extra_javascript`, `plugins`, - `markdown_extensions`, `nav`, `extra`, and `watch` settings. -5. Links, anchors, generated API/help pages, redirects, and CI publishing steps. -6. Existing custom CSS, JavaScript, templates, logos, favicons, and fonts. +For a Process-PSModule consumer, retain the template's +`.github/zensical.toml` configuration and the generated site layout. Do not +add a second active site configuration. -Do not assume every consumer has a `docs/` tree. If documentation is absent, -create it only when the requested scope includes documentation migration. If -the repository already uses Zensical, compare it with the Process-PSModule -baseline and make only the required alignment changes. - -## Target layout - -For the Process-PSModule documentation contract, use: - -```text -docs/ -├── content/ -├── overrides/ -│ └── assets/ -│ ├── javascripts/ -│ └── stylesheets/ -└── zensical.toml -``` - -Keep existing content under `docs/content/`, custom templates under -`docs/overrides/`, and theme assets under `docs/overrides/assets/`. A separate -`docs/assets/` directory is optional for static content assets. Do not create a -parallel MkDocs theme or leave two active site configurations. - -The current `PSModule/Template-PSModule` repository historically stores a -starter `.github/zensical.toml` and may not contain a `docs/` tree. When the -consumer upgrade explicitly requires the Process-PSModule `docs/` contract, -move the template settings and custom assets into `docs/` and remove the -obsolete active configuration only after the site builds. When that contract -is not in scope, preserve a working template layout and report the difference -instead of moving files speculatively. - -## MkDocs-to-Zensical mapping - -Translate behavior, not just filenames: - -| MkDocs | Zensical | -| --- | --- | -| `site_name`, `site_url` | `[project]` metadata | -| `docs_dir` | `[project].docs_dir` | -| `repo_name`, `repo_url`, `edit_uri` | `[project]` metadata | -| `nav` | `nav = [...]` TOML entries | -| `theme.name` | `[project.theme]` settings | -| `theme.custom_dir` | `[project.theme].custom_dir` | -| `theme.logo`, `theme.favicon` | `[project.theme]` paths | -| `theme.features` | `[project.theme].features` | -| `theme.palette` | `[[project.theme.palette]]` tables | -| `extra_css`, `extra_javascript` | `[project]` arrays | -| `markdown_extensions` | `[project.markdown_extensions.*]` tables | -| `plugins: search` | `[project.plugins.search]` | -| `extra.social` | `[[project.extra.social]]` | -| `watch` | `[project].watch` | - -Preserve Markdown semantics while checking extensions that affect formatting: -admonitions, fenced code, tables, task lists, definition lists, attributes, -snippets, Mermaid, and anchor/permalink behavior. Fix only documented -Zensical incompatibilities; do not silently change headings or links to hide -build failures. - -## Content and link migration - -- Preserve page paths and navigation labels where possible. -- Keep explicit `nav` entries for important landing pages and references. -- Resolve relative links from the new `docs/content/` root. -- Recheck fragment anchors because heading and permalink behavior can differ. -- Move includes/snippets to the configured Zensical location and update every - reference. -- Keep generated API/help inputs in their framework-owned locations. -- Preserve images and downloads; update paths rather than deleting assets. -- Keep custom templates only when they are still required by the migrated site. +When a requested migration needs authored Markdown content or a custom theme, +record that the framework must first stage those assets into `outputs/site`. +Do not claim the migration is complete until the producer supports the target +layout and a consumer build publishes it successfully. ## Validation -Run repository-native checks and the smallest targeted site checks first: - -1. Parse the new `docs/zensical.toml` and verify all configured paths exist. -2. Confirm no active `mkdocs.yml` or MkDocs workflow remains. -3. Check every navigation target, image, download, include, and fragment link. -4. Run the existing documentation lint and link checks. -5. Build the site: - - ```powershell - Push-Location docs - zensical build --clean - Pop-Location - ``` - -6. Review the generated site for navigation, search, palette toggles, fonts, - logo/favicon, custom navigation behavior, code blocks, Mermaid, and social - links. -7. Run the repository's existing Process-PSModule workflow validation when the - documentation is part of that pipeline. +1. Confirm the caller references the intended Process-PSModule version and + retains `.github/zensical.toml`. +2. Run the consumer's existing documentation and Process-PSModule validation + commands when available. +3. Review the generated site to confirm generated function documentation, + `README.md`, and module assets remain present. -Do not claim success if the site builds while links, assets, navigation, or -publishing behavior are broken. Report intentionally deferred pages, -unsupported extensions, and unrelated pre-existing failures. +Report any unsupported authored-content or custom-theme requirement as a +framework gap rather than working around it with a second active +configuration. ## References -- [Process-PSModule Zensical configuration](https://github.com/PSModule/Process-PSModule/blob/main/docs/zensical.toml) -- [Process-PSModule documentation overrides](https://github.com/PSModule/Process-PSModule/tree/main/docs/overrides) -- [Process-PSModule override assets](https://github.com/PSModule/Process-PSModule/tree/main/docs/overrides/assets) - [PSModule repository standard](https://github.com/PSModule/Process-PSModule/blob/main/docs/content/reference/repository-standard.md) -- [PSModule documentation model](https://github.com/PSModule/Process-PSModule/blob/main/docs/content/guides/structuring-your-module.md) +- [PSModule workflow inputs](https://github.com/PSModule/Process-PSModule/blob/main/docs/content/reference/workflow-inputs.md) - [Zensical setup basics](https://zensical.org/docs/setup/basics/) From d6c909bd46260d3b212e5560df89234da3974c57 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 5 Sep 2026 08:20:52 +0200 Subject: [PATCH 13/13] Restore single caller concurrency policy Return caller guidance and direct workflow tests to the experimentally adopted workflow-level concurrency group and one reusable-workflow calling job. Keep public caller permissions scoped to that job. Co-authored-by: Copilot <223556698+Copilot@users.noreply.github.com> --- .../skills/psmodule-v8-upgrade/SKILL.md | 36 ++++------- .github/workflows/Workflow-Test-Default.yml | 47 +++----------- .../workflows/Workflow-Test-WithManifest.yml | 47 +++----------- docs/content/get-started/repository-setup.md | 37 ++++------- docs/content/guides/calling-the-workflow.md | 46 +++++--------- .../guides/github-app-authentication.md | 11 +--- docs/content/reference/repository-standard.md | 13 ++-- docs/content/specification/design.md | 5 +- .../specification/workflow-triggers/design.md | 63 +++++++++---------- .../specification/workflow-triggers/spec.md | 19 +++--- 10 files changed, 101 insertions(+), 223 deletions(-) diff --git a/.github/plugin/psmodule/skills/psmodule-v8-upgrade/SKILL.md b/.github/plugin/psmodule/skills/psmodule-v8-upgrade/SKILL.md index 646cf4f9..337cf0af 100644 --- a/.github/plugin/psmodule/skills/psmodule-v8-upgrade/SKILL.md +++ b/.github/plugin/psmodule/skills/psmodule-v8-upgrade/SKILL.md @@ -154,28 +154,13 @@ on: - labeled - unlabeled -jobs: - Process-PSModule-Production: - if: ${{ github.event_name != 'pull_request' }} - concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - queue: max - permissions: - contents: read - pages: write - id-token: write - uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v8 - secrets: - PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} - GitHubAppClientId: ${{ secrets.SHELLY_CLIENT_ID }} - GitHubAppPrivateKey: ${{ secrets.SHELLY_PRIVATE_KEY }} +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + queue: ${{ github.event_name == 'pull_request' && 'single' || 'max' }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} - Process-PSModule-PullRequest: - if: ${{ github.event_name == 'pull_request' }} - concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number }} - queue: single - cancel-in-progress: true +jobs: + Process-PSModule: permissions: contents: read pages: write @@ -187,10 +172,11 @@ jobs: GitHubAppPrivateKey: ${{ secrets.SHELLY_PRIVATE_KEY }} ``` -The only permitted variation is an optional identical `TestData` entry under both caller jobs' `secrets` mappings. -Do not add `with:` inputs, extra jobs, conditions, schedule changes, `run-name`, permission changes, trigger changes, -concurrency changes, debug options, or version overrides. Repository-owned automation belongs in separate workflow -files. +The only permitted variation is an optional `TestData` entry under +`jobs.Process-PSModule.secrets`. Do not add `with:` inputs, extra jobs, +conditions, schedule changes, `run-name`, permission changes, trigger changes, +concurrency changes, debug options, or version overrides. Repository-owned +automation belongs in separate workflow files. ### TestData preservation diff --git a/.github/workflows/Workflow-Test-Default.yml b/.github/workflows/Workflow-Test-Default.yml index f04c4db3..51f6c7ad 100644 --- a/.github/workflows/Workflow-Test-Default.yml +++ b/.github/workflows/Workflow-Test-Default.yml @@ -30,45 +30,16 @@ on: schedule: - cron: '0 0 * * *' +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + queue: ${{ github.event_name == 'pull_request' && 'single' || 'max' }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + permissions: {} jobs: WorkflowTestDefault: - if: ${{ github.event_name != 'pull_request' }} - concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - queue: max - permissions: - contents: read - pages: write - id-token: write - uses: ./.github/workflows/workflow.yml - secrets: - PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} - GitHubAppClientId: ${{ secrets.SHELLY_CLIENT_ID }} - GitHubAppPrivateKey: ${{ secrets.SHELLY_PRIVATE_KEY }} - TestData: >- - { - "secrets": { - "PSMODULE_TEST_SINGLELINE_SECRET": "${{ secrets.PSMODULE_TEST_SINGLELINE_SECRET }}" - }, - "variables": { - "PSMODULE_TEST_VARIABLE": ${{ toJSON(vars.PSMODULE_TEST_VARIABLE) }} - } - } - with: - WorkingDirectory: tests/srcTestRepo - ImportantFilePatterns: | - ^tests/srcTestRepo/ - ^\.github/actions/ - ^\.github/workflows/(?!Release\.yml$|Linter\.yml$) - - WorkflowTestDefaultPullRequest: - if: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }} - concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number }} - queue: single - cancel-in-progress: true + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} permissions: contents: read pages: write @@ -95,15 +66,11 @@ jobs: ^\.github/workflows/(?!Release\.yml$|Linter\.yml$) VerifyRootFunctionsIndexDefault: - if: >- - ${{ always() && github.event.action != 'closed' && - (needs.WorkflowTestDefault.result == 'success' || - needs.WorkflowTestDefaultPullRequest.result == 'success') }} + if: github.event.action != 'closed' name: Verify root Functions index [Default] runs-on: ubuntu-latest needs: - WorkflowTestDefault - - WorkflowTestDefaultPullRequest permissions: contents: read steps: diff --git a/.github/workflows/Workflow-Test-WithManifest.yml b/.github/workflows/Workflow-Test-WithManifest.yml index 41b59181..7b12e43f 100644 --- a/.github/workflows/Workflow-Test-WithManifest.yml +++ b/.github/workflows/Workflow-Test-WithManifest.yml @@ -30,45 +30,16 @@ on: schedule: - cron: '0 0 * * *' +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + queue: ${{ github.event_name == 'pull_request' && 'single' || 'max' }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + permissions: {} jobs: WorkflowTestWithManifest: - if: ${{ github.event_name != 'pull_request' }} - concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - queue: max - permissions: - contents: read - pages: write - id-token: write - uses: ./.github/workflows/workflow.yml - secrets: - PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} - GitHubAppClientId: ${{ secrets.SHELLY_CLIENT_ID }} - GitHubAppPrivateKey: ${{ secrets.SHELLY_PRIVATE_KEY }} - TestData: >- - { - "secrets": { - "PSMODULE_TEST_SINGLELINE_SECRET": "${{ secrets.PSMODULE_TEST_SINGLELINE_SECRET }}" - }, - "variables": { - "PSMODULE_TEST_VARIABLE": ${{ toJSON(vars.PSMODULE_TEST_VARIABLE) }} - } - } - with: - WorkingDirectory: tests/srcWithManifestTestRepo - ImportantFilePatterns: | - ^tests/srcWithManifestTestRepo/ - ^\.github/actions/ - ^\.github/workflows/(?!Release\.yml$|Linter\.yml$) - - WorkflowTestWithManifestPullRequest: - if: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }} - concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number }} - queue: single - cancel-in-progress: true + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} permissions: contents: read pages: write @@ -95,15 +66,11 @@ jobs: ^\.github/workflows/(?!Release\.yml$|Linter\.yml$) VerifyRootFunctionsIndexWithManifest: - if: >- - ${{ always() && github.event.action != 'closed' && - (needs.WorkflowTestWithManifest.result == 'success' || - needs.WorkflowTestWithManifestPullRequest.result == 'success') }} + if: github.event.action != 'closed' name: Verify root Functions index [WithManifest] runs-on: ubuntu-latest needs: - WorkflowTestWithManifest - - WorkflowTestWithManifestPullRequest permissions: contents: read steps: diff --git a/docs/content/get-started/repository-setup.md b/docs/content/get-started/repository-setup.md index 8aede537..fc21fb81 100644 --- a/docs/content/get-started/repository-setup.md +++ b/docs/content/get-started/repository-setup.md @@ -71,28 +71,13 @@ on: - labeled - unlabeled -jobs: - Process-PSModule-Production: - if: ${{ github.event_name != 'pull_request' }} - concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - queue: max - permissions: - contents: read - pages: write - id-token: write - uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v8 - secrets: - PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} - GitHubAppClientId: ${{ secrets.SHELLY_CLIENT_ID }} - GitHubAppPrivateKey: ${{ secrets.SHELLY_PRIVATE_KEY }} +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + queue: ${{ github.event_name == 'pull_request' && 'single' || 'max' }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} - Process-PSModule-PullRequest: - if: ${{ github.event_name == 'pull_request' }} - concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number }} - queue: single - cancel-in-progress: true +jobs: + Process-PSModule: permissions: contents: read pages: write @@ -104,16 +89,16 @@ jobs: GitHubAppPrivateKey: ${{ secrets.SHELLY_PRIVATE_KEY }} ``` -Every permission on the calling jobs is required. GitHub App installation tokens perform repository writes. A push to +Every permission on the calling job is required. GitHub App installation tokens perform repository writes. A push to `main` publishes a stable release after the full pipeline passes; the pull-request trigger handles CI, prereleases, and prerelease cleanup. See [Workflow inputs](../reference/workflow-inputs.md) for what each permission is used for, and [Calling the workflow](../guides/calling-the-workflow.md) for passing test secrets and variables. -The production job retains pushes, dispatches, and scheduled work in the maximum native queue. The pull-request job -cancels obsolete activity and uses the pull-request number for every action, including `closed`. GitHub requires a -literal queue value and does not permit cancellation with `queue: max`, so the two jobs use separate compatible -policies. Keep both groups distinct from the reusable workflow's prefixed group. +The caller-level concurrency block retains production, dispatch, and scheduled work in the maximum native queue while +replacing obsolete activity for the same pull request. Its fallback expression uses the pull-request number for every +pull-request action, including `closed`; other events use their Git ref. Keep its group distinct from the reusable +workflow's prefixed group. ## 5. Configure the settings file diff --git a/docs/content/guides/calling-the-workflow.md b/docs/content/guides/calling-the-workflow.md index 75524943..6f6a5304 100644 --- a/docs/content/guides/calling-the-workflow.md +++ b/docs/content/guides/calling-the-workflow.md @@ -36,28 +36,13 @@ on: - labeled - unlabeled -jobs: - Process-PSModule-Production: - if: ${{ github.event_name != 'pull_request' }} - concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - queue: max - permissions: - contents: read - pages: write - id-token: write - uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v8 - secrets: - PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} - GitHubAppClientId: ${{ secrets.SHELLY_CLIENT_ID }} - GitHubAppPrivateKey: ${{ secrets.SHELLY_PRIVATE_KEY }} +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + queue: ${{ github.event_name == 'pull_request' && 'single' || 'max' }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} - Process-PSModule-PullRequest: - if: ${{ github.event_name == 'pull_request' }} - concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number }} - queue: single - cancel-in-progress: true +jobs: + Process-PSModule: permissions: contents: read pages: write @@ -75,11 +60,10 @@ Stable releases are evaluated from a push to the default branch. A merged pull r release notes; a direct default-branch push or a manual dispatch uses the default `Patch` bump and commit-based notes. Keep the `pull_request` trigger for CI, prereleases, and prerelease cleanup. -The production job serializes default-branch pushes, manual releases, and scheduled work in the maximum native queue. -The pull-request job uses a separate group for each pull request, so a close event interrupts only its own activity and -does not block the resulting stable release. Its `single` queue and cancellation replace obsolete activity. GitHub -requires a literal queue value and rejects cancellation with `queue: max`, so these policies must remain separate. -The reusable workflow uses a distinct prefixed concurrency group. Do not give either caller job that group name. +The concurrency group uses the pull-request number when available and the Git ref otherwise, so a close event +interrupts only its own pull-request activity and does not block the resulting stable release. Pull-request events use +the `single` queue and cancel obsolete activity; other events use the maximum native queue without cancellation. The +reusable workflow uses a distinct prefixed concurrency group. Do not give the caller that group name. ## Passing test data @@ -101,9 +85,9 @@ The reusable workflow accepts test data through `TestData` and no longer declare - `TEST_USER_USER_FG_PAT` - `TEST_USER_PAT` -If a caller passed any of these secrets directly, place them in the `secrets` map inside `TestData`. Add the same -`TestData` mapping to the `secrets` block of both caller jobs. The environment variable names used by the tests can -stay the same; only the workflow-call interface changes: +If a caller passed any of these secrets directly, place them in the `secrets` map inside `TestData`. Add the +`TestData` mapping to the calling job's `secrets` block. The environment variable names used by the tests can stay the +same; only the workflow-call interface changes: ```yaml TestData: >- @@ -122,8 +106,8 @@ workflow. It is one JSON object with two maps, so everything the tests need is v ``` Values under `secrets` are masked in the logs; values under `variables` are not. Build it in the calling workflow and -pass it through the `secrets:` blocks of both caller jobs so the whole blob is masked. Reference each secret directly -as `"${{ secrets. }}"` and each variable as `${{ toJSON(vars.) }}`. A folded `>-` scalar keeps the source +pass it through the calling job's `secrets:` block so the whole blob is masked. Reference each secret directly as +`"${{ secrets. }}"` and each variable as `${{ toJSON(vars.) }}`. A folded `>-` scalar keeps the source readable while producing a single-line value, as long as the JSON content lines stay at the same indentation level: ```yaml diff --git a/docs/content/guides/github-app-authentication.md b/docs/content/guides/github-app-authentication.md index a16748a8..cfb18c38 100644 --- a/docs/content/guides/github-app-authentication.md +++ b/docs/content/guides/github-app-authentication.md @@ -19,18 +19,11 @@ The reusable workflow declares three required secrets at its `workflow_call` bou | `GitHubAppPrivateKey` | The GitHub App private key passed to the token action. | The names are the reusable workflow contract, not a requirement for the caller's repository or organization secret -names. Map the caller's secrets explicitly in both caller jobs: +names. Map the caller's secrets explicitly in the calling job: ```yaml jobs: - Process-PSModule-Production: - uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v8 - secrets: - PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} - GitHubAppClientId: ${{ secrets.SHELLY_CLIENT_ID }} - GitHubAppPrivateKey: ${{ secrets.SHELLY_PRIVATE_KEY }} - - Process-PSModule-PullRequest: + Process-PSModule: uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v8 secrets: PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} diff --git a/docs/content/reference/repository-standard.md b/docs/content/reference/repository-standard.md index 8f264cc5..14d2f580 100644 --- a/docs/content/reference/repository-standard.md +++ b/docs/content/reference/repository-standard.md @@ -124,18 +124,15 @@ repository. Name it `Process-PSModule.yml`, matching `workflow.yml` is the reusable workflow's own filename inside `PSModule/Process-PSModule` and belongs only in the `uses:` reference. -The caller uses these mutually exclusive jobs: +The caller uses one `Process-PSModule` job. Its workflow-level concurrency group uses the pull-request number when +available and the Git ref otherwise. Pull-request events use `queue: single` and cancel obsolete activity; other +events use `queue: max` without cancellation. -| Job | Events | Concurrency policy | -| --- | --- | --- | -| `Process-PSModule-Production` | Non-pull-request events | Group by Git ref with `queue: max`; do not cancel runs. | -| `Process-PSModule-PullRequest` | Pull-request events | Group by pull-request number with `queue: single`; cancel obsolete runs. | - -Both jobs use the same reusable-workflow reference, required job-level permissions, and explicit `PSGALLERY_API_KEY`, +The job uses the reusable-workflow reference, required job-level permissions, and explicit `PSGALLERY_API_KEY`, `GitHubAppClientId`, and `GitHubAppPrivateKey` mapping. The exact [caller template](../guides/calling-the-workflow.md) is part of this standard. Use the controlled `PSModule/Process-PSModule` `@v8` major reference; pin external Actions dependencies to a commit SHA with the version -tag in a trailing comment so Dependabot can update them. Permissions are declared only on the caller jobs, not at the +tag in a trailing comment so Dependabot can update them. Permissions are declared only on the calling job, not at the workflow root. ## Required common files diff --git a/docs/content/specification/design.md b/docs/content/specification/design.md index 1318233a..22330371 100644 --- a/docs/content/specification/design.md +++ b/docs/content/specification/design.md @@ -20,8 +20,9 @@ documented in [Workflow inputs](../reference/workflow-inputs.md). ### Trigger admission The [workflow trigger design](workflow-triggers/design.md) owns caller admission before processing: a retained -production queue and replaceable pull-request activity. The caller jobs cover each complete reusable-workflow call -through its final enabled stage; the reusable workflow identifies closure and performs optional prerelease cleanup. +production queue and replaceable pull-request activity. The caller-level concurrency group covers each complete +reusable-workflow call through its final enabled stage; the reusable workflow identifies closure and performs optional +prerelease cleanup. ### Composed reusable workflows diff --git a/docs/content/specification/workflow-triggers/design.md b/docs/content/specification/workflow-triggers/design.md index 65f6b846..d26699d7 100644 --- a/docs/content/specification/workflow-triggers/design.md +++ b/docs/content/specification/workflow-triggers/design.md @@ -1,12 +1,12 @@ --- title: Workflow triggers - Design -description: Use separate caller-owned GitHub Actions job policies for retained production and replaceable pull-request work. +description: Use one caller-owned GitHub Actions concurrency group for retained production and replaceable pull-request work. --- # Workflow triggers - Design -The caller workflow uses two mutually exclusive calling jobs for `.github/workflows/workflow.yml`. One retains -non-pull-request work; the other replaces activity for an individual pull request. The reusable workflow selects its +The caller workflow holds one concurrency slot for its complete call to `.github/workflows/workflow.yml`. It retains +non-pull-request work, replaces activity for an individual pull request, and lets the reusable workflow select its normal processing or close behavior from the incoming event. ## Specification @@ -15,35 +15,30 @@ normal processing or close behavior from the incoming event. ## Approach -Every caller uses these job-level policies: +Every caller uses this workflow-level configuration: ```yaml -jobs: - Process-PSModule-Production: - if: ${{ github.event_name != 'pull_request' }} - concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - queue: max - - Process-PSModule-PullRequest: - if: ${{ github.event_name == 'pull_request' }} - concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number }} - queue: single - cancel-in-progress: true +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + queue: ${{ github.event_name == 'pull_request' && 'single' || 'max' }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} ``` -Each calling job uses the same permissions, reusable-workflow reference, and secret mapping. The caller workflow name -scopes both groups from other workflows in the same repository, so no additional prefix is needed. GitHub requires a -literal queue value and rejects `queue: max` with `cancel-in-progress: true`; separate jobs are therefore required to -apply both policies. +The group expression is a fallback, not concatenation. A pull-request event uses its number; every other event uses +its Git ref. The caller workflow name scopes the group from other workflow names in the same repository, so no +additional prefix is needed. + +The queue expression distinguishes the two policies: + +- Pull-request events use the one-pending replacement queue and cancel active work. +- All other events use the maximum retained queue and do not cancel active work. This includes `push`, + `workflow_dispatch`, and any scheduled event. ## Alternatives considered | Option | Trade-offs | Verdict | | --- | --- | --- | -| Two caller job-level policies | Each complete reusable-workflow call has a static, compatible queue and cancellation policy. | Chosen. | -| One conditional caller workflow-level group | GitHub requires a literal `queue` value and rejects `queue: max` with cancellation enabled. | Rejected. | +| One conditional caller workflow-level group | Covers the complete reusable-workflow call with three expressions and preserves the required event behavior. | Chosen. | | One conditional producer workflow-level group | A live experiment failed concurrent production admission before jobs began. | Rejected. | | One caller group with `queue: max` and no cancellation | Retains every event but does not converge pull-request activity or prioritize closure. | Rejected. | | Per-track router jobs | Separates closure from activity but adds nested reusable workflows, receipts, and coordination beyond the required caller setting. | Rejected. | @@ -53,11 +48,11 @@ apply both policies. | Component | Responsibility | | --- | --- | -| Caller workflow | Subscribes to events and routes each event to the calling job with its concurrency policy. | +| Caller workflow | Subscribes to events and applies the concurrency group before invoking the reusable workflow. | | GitHub Actions | Retains non-pull-request work or cancels superseded pull-request work for the matching group. | | `workflow.yml` | Receives the original caller event and routes it through the existing processing or close path. | -Each caller job-level group covers all nested reusable jobs until the calling job completes. It therefore serializes +The caller-level group covers all nested reusable jobs until the calling job completes. It therefore serializes planning, version resolution, publication, and enabled teardown rather than only a short admission step. ## Data and contracts @@ -76,11 +71,11 @@ different group and proceeds independently. ## Reusable workflow compatibility -The caller groups are deliberately unprefixed. The current reusable workflow uses +The caller group is deliberately unprefixed. The current reusable workflow uses `Process-PSModule-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}`. The names differ, -which prevents the cancellation-enabled caller job from canceling its own caller job through the called workflow. +which prevents a cancellation-enabled caller group from canceling its own caller job through the called workflow. -Do not add `Process-PSModule-` to either caller group while that reusable group exists. The group names are +Do not add `Process-PSModule-` to the caller group while that reusable group exists. The group names are repository-local and case-insensitive. If a future reusable workflow removes its group, this caller configuration remains valid without modification. @@ -93,13 +88,17 @@ reusable workflow retains responsibility for credential handling and for evaluat Use a disposable, nonpublishing caller and producer. Verify three competing production pushes finish serially, a manual run waits behind production, rapid updates cancel activity for one pull request without affecting another, and -a close event cancels activity before the close path runs. Confirm the pull-request job uses `queue: single` and the -production job uses `queue: max`. +a close event cancels activity before the close path runs. + +Live GitHub Actions experiments validated the configuration with a distinct prefixed group in the reusable producer: +three production runs completed serially; a manual run waited behind a push and executed the production path; three +rapid pull-request activity runs were canceled; and the closure run completed its close job. A separate experiment +showed that placing the conditional group in the producer fails under concurrent production admission. ## Rollout and operability -Replace the caller's existing generic calling job with the documented production and pull-request jobs. No new input, -secret, dispatcher, or reusable-workflow version is required. Keep both groups distinct from the reusable workflow's +Replace the caller's existing `cancel-in-progress: false` block with the documented configuration. No new input, +secret, dispatcher, or reusable-workflow version is required. Keep the group distinct from the reusable workflow's prefixed group. GitHub retains one running and up to 100 pending executions for a `max` group. It does not guarantee commit-order diff --git a/docs/content/specification/workflow-triggers/spec.md b/docs/content/specification/workflow-triggers/spec.md index 48b46b9b..af3b41a1 100644 --- a/docs/content/specification/workflow-triggers/spec.md +++ b/docs/content/specification/workflow-triggers/spec.md @@ -1,11 +1,11 @@ --- title: Workflow triggers - Spec -description: Retain production work while superseding obsolete pull-request activity with separate caller policies. +description: Retain production work while superseding obsolete pull-request activity with one caller concurrency group. --- # Workflow triggers - Spec -The caller workflow admits Process-PSModule work with separate native GitHub Actions policies. Default-branch +The caller workflow admits Process-PSModule work with one native GitHub Actions concurrency group. Default-branch production and manual releases are retained; activity for each pull request converges on its latest event; a close event supersedes that pull request's activity before the reusable workflow evaluates optional prerelease cleanup. @@ -41,22 +41,21 @@ ownership or deletion implementation. ## Functional requirements -### FR1 - Admit work through caller policies {#fr1} +### FR1 - Admit work through one caller policy {#fr1} -The caller workflow MUST apply a retained policy to non-pull-request work and a superseding policy to pull-request -work. The retained policy MUST group work by Git ref without canceling it; the superseding policy MUST group work by -pull-request number and cancel obsolete activity. Each policy MUST cover its complete reusable-workflow call. The -reusable workflow selects processing or close behavior from the original event. +The caller workflow MUST define one workflow-level concurrency group for the complete reusable-workflow call. The +group MUST use the pull-request number when available and the Git ref otherwise. It MUST retain non-pull-request work +and replace pull-request work. The reusable workflow selects processing or close behavior from the original event. #### Caller admission scenario ```gherkin Scenario: Call the workflow Given a caller subscribes to default-branch and pull-request events - And it declares the documented concurrency policies + And it declares the documented concurrency group When it calls Process-PSModule - Then each complete reusable-workflow call has its required admission policy - And no dispatcher is required + Then its complete reusable-workflow call has one admission policy + And no dispatcher or per-track caller jobs are required ``` ### FR2 - Retain and serialize production work {#fr2}