Reusable automation can centralize a bad assumption
GitHub Actions reusable workflows removed release duplication in late 2021 while turning one shared definition into versioned, credentialed infrastructure for every calling repository.
By late 2021, three of my personal repositories had copied versions of the same GitHub Actions release workflow.
M31V built a static publication. Z29C built a service and migration artifact. D4U7 built a client and server release. Their jobs all checked out source, selected a runtime, installed locked dependencies, ran verification, assembled a manifest, and retained evidence.
The copies had drifted. One still used an older runtime setup. Another omitted the manifest check. A third fixed secret scoping without carrying the change back.
GitHub Actions made reusable workflows generally available in November 2021, allowing one workflow to call another whole workflow through a declared interface. The feature could remove duplication.
It could also centralize one bad assumption across every repository at once.
Duplication had been a weak form of isolation
Copied YAML was wasteful and visible. A change in one repository affected only that repository until I deliberately carried it elsewhere.
That isolation limited blast radius. It also guaranteed drift. A security correction, runtime update, or artifact-identity rule could remain inconsistent for months.
A reusable workflow changed the relationship. Callers referenced a shared definition. Updating the referenced version could align them. Pointing callers at a movable reference could change several release paths without a diff in those repositories.
The design decision was not simply DRY versus copy-and-paste. It was where change authority should live and how consumers would adopt it.
I wanted one verified implementation of common release evidence and explicit local ownership of product-specific behavior.
The shared boundary needed to be smaller than “deploy anything.”
I shared verification before deployment
The first candidate combined build and release. Inputs selected runtime, build command, artifact path, and target. Secrets allowed several deployment mechanisms.
The interface was flexible and dangerous. A stringly typed command input could execute arbitrary repository-controlled shell. A broad secret set meant every caller granted the shared workflow more authority than most jobs needed. Product-specific deployment semantics disappeared behind options.
I began with a reusable verification workflow:
- Select one declared supported runtime.
- Install from the committed lockfile.
- Run repository-owned verification entry points.
- Build through a fixed script contract.
- Validate and expose the artifact manifest.
- Return bounded outputs such as artifact digest and definition version.
Deployment remained local until its target contract was equally clear.
This removed repetitive setup while keeping side-effecting authority close to each project's release semantics.
Reuse entered through the lowest-consequence common boundary.
The interface was an API
The reusable workflow accepted inputs and, where necessary, secrets through workflow_call. I treated that surface like a versioned API.
Each input needed:
- Name and type supported by the 2021 feature surface.
- Required or optional status.
- Default with clear semantics.
- Validation before use.
- Whether repository content could influence it.
- Whether it affected artifact identity or authority.
Outputs named stable claims: selected runtime, manifest digest, artifact identifier, and verification result. They did not expose internal job names callers would accidentally depend on.
I avoided accepting raw command strings. The caller supplied a small build-profile name, and the repository implemented a fixed verify and build-release script. Unknown profiles failed.
The YAML file had become a callable interface. Review needed to consider backward compatibility and injection paths, not only whether the steps ran.
Pinning made change deliberate
Referencing a reusable workflow by a floating branch would let the shared repository alter callers the next time they ran. That was convenient for fixes and unsafe for release reproducibility.
Callers referenced an immutable commit or a deliberately managed version tag whose movement policy was explicit. The release manifest recorded the resolved workflow revision.
An immutable commit gave strongest reproducibility and required pull requests in each caller for updates. A version tag reduced noise and made trust depend on tag governance. I used immutable references for the highest-authority release path and a tested version line for lower-risk verification where the trade was acceptable.
The shared workflow published change notes and compatibility fixtures. A caller update was a dependency update with a diff, not invisible inheritance.
Pinning created maintenance work. That work was the mechanism by which a central change became a local decision.
Shared code should be easy to update and difficult to mutate accidentally.
A fix could need coordinated adoption
Suppose the shared workflow discovered that artifact manifests omitted the runtime patch version. Requiring the field immediately could break every caller whose build script produced the older shape.
I used an expand-and-contract path:
- Shared workflow version accepts old and new manifests, warns on old, and exposes format version.
- Callers update their build scripts and verification fixtures.
- Shared workflow version requires the new field.
- Callers adopt the stricter version deliberately.
For a security issue, the path could compress. A vulnerable shared version would be marked unsupported, and affected callers would need an urgent update. Pinning meant they would not silently repair themselves; inventory and visible dependency ownership were therefore essential.
The personal repository set was small enough to maintain a matrix of caller, pinned workflow version, artifact format, and last verified update.
Centralization makes rollout a product of its own. One fix is not complete when only the shared file changes.
Secrets crossed a trust boundary
Reusable workflows could receive named secrets from callers. That feature made central deployment tempting and raised the most important review question: which shared code could read which credential?
I passed individual secrets explicitly and only to jobs that needed them. The shared verification workflow received none. A later reusable publishing workflow received a narrowly scoped upload capability, not a general host shell credential.
The destination interface enforced artifact upload and activation under immutable identity. Even if workflow code misbehaved, the credential could not change arbitrary paths.
Logs used allowlisted diagnostics. The workflow never printed complete environments. Artifacts excluded local configuration and secret-bearing files. Failure fixtures included recognizable fake credentials and searched retained logs and outputs.
Referencing shared automation grants its code the authority available to the called job. That grant should look as consequential in review as adding a dependency with deployment credentials.
Secret masking was a display protection, not a permission model.
The shared workflow executed scripts and dependencies from the calling repository. Central, reviewed YAML did not make caller source safe.
A pull-request build could alter package.json, install scripts, tests, or the repository's verify entry point. It did not receive release credentials. Trusted release ran only from an accepted revision under the caller's event policy.
The reusable workflow validated inputs and separated verification from publishing. It did not build untrusted proposed code in a context that also held deployment authority merely because the central file itself was trusted.
Artifact promotion needed immutable identity linking tested source, lockfile, build recipe, workflow version, and output digest. If release rebuilt, it compared manifests and retained the limitation. If it promoted, storage and access made the exact tested artifact explicit.
Shared automation can centralize steps. It cannot dissolve the trust difference between proposed and accepted code.
Repository scripts kept platform logic thin
I avoided moving the complete build algorithm into the reusable workflow. YAML conditionals and expressions would make local reproduction and testing difficult.
Repositories retained deterministic entry points:
./scripts/verify
./scripts/build-release
./scripts/check-manifestThe shared workflow supplied the hosted environment, called the scripts, collected outputs, and enforced cross-repository evidence rules.
Project-specific behavior remained versioned with the project. The shared layer did not need a boolean option for every exception. If two projects truly required different build contracts, they used different profiles or did not share that part.
This also made migration away from GitHub Actions possible. The release contract could run locally or under another orchestrator. Platform integration added value without owning the whole algorithm.
Reuse reduced orchestration duplication while preserving product differences.
Outputs were claims, not convenience variables
A reusable workflow could expose outputs to its caller. I treated each as a receipt:
artifact-digest: digest of the immutable output actually retained.manifest-digest: identity of declared build inputs and artifact mapping.verification-revision: source revision whose checks ran.workflow-revision: shared definition that produced the result.
The publishing job checked the relationships. A digest from one source revision could not be activated under another tag. A missing output did not fall back to latest.
I avoided outputs such as success=true; the job conclusion already represented bounded execution, and dependant work needed specific evidence.
Z29C's receipt model influenced this design. Automation should make the evidence required by the next step explicit.
The workflow call became a boundary with typed-enough inputs and semantically named outputs, even though YAML could not express every constraint.
Reuse could propagate a wrong default
The first shared workflow defaulted to the newest supported Node runtime when the caller omitted one. That sounded helpful and made artifact identity depend on the day the shared workflow changed.
I required a declared release runtime or a repository file included in the build manifest. Verification matrices could test several versions; release output used one explicit environment.
Other dangerous defaults included:
- Publishing when a target input was present instead of requiring a separate workflow.
- Uploading every file under a broad build directory.
- Inheriting all available secrets.
- Running on any event the caller forwarded.
- Treating missing manifest fields as warnings indefinitely.
- Using a mutable artifact name.
A local bad default harms one repository. A reusable bad default becomes policy through repetition.
I made consequential inputs required and allowed convenience only where the fallback was stable, visible, and low risk.
Reuse increased the return on good design and the blast radius of ambiguity.
The reusable workflow defined jobs. The calling workflow decided which repository event invoked it and under what revision.
I kept that distinction visible. A shared verification job did not imply every caller should run it with the same branch, path, or release trigger. A shared publishing job did not grant a tag pattern authority by itself.
The caller owned:
- Pull request versus push versus deliberate release event.
- Accepted branch or tag policy.
- Repository-specific path filters.
- Whether required checks and review conditions had completed.
- Which target and credential scope applied.
The called workflow validated its assumptions but did not infer trust from being invoked. A publishing boundary checked artifact and source identity again.
Centralizing event policy prematurely would turn different repository lifecycles into an option maze or a lowest-common-denominator trigger.
Shared execution and local activation were separate responsibilities.
Dependency review usually focused on packages and Actions. The called workflow could execute many Actions, shell commands, containers, and scripts under one reference.
I inventoried its transitive execution:
- Referenced Actions and immutable versions.
- Hosted runner assumptions.
- Package installation behavior.
- Container images where used.
- Repository scripts invoked.
- Credentials made available.
- Network targets contacted.
Updating the workflow revision could update several transitive dependencies. Change notes highlighted them.
I kept the shared workflow small and used official foundational Actions where practical. A convenience Action that wrapped one transparent command often lost to the command itself; nontrivial integrations justified their dependency when source and update path were clear.
One line in a caller did not mean one line of authority.
Contract scenarios ran against caller fixtures
The shared workflow repository could validate YAML and scripts in isolation. Real compatibility depended on caller shapes.
I maintained small fixture repositories or captured fixture layouts for:
- Node application with lockfile and manifest.
- Static publication with immutable assets.
- Service artifact plus migration metadata.
- Missing required runtime declaration.
- Changed lockfile after candidate verification.
- Malformed artifact manifest.
- Secret-like file accidentally included in output.
- Untrusted pull request attempting to reach publish path.
Candidate workflow versions ran the fixtures before callers updated. Each real repository still ran its own checks under the pinned candidate revision.
The tests looked for failure behavior as much as success: stop before upload, name the missing claim, preserve logs without secrets, and never fall back to mutable identity.
Shared automation needed integration tests because its defects were integrations by definition.
Failure still belonged to the calling workflow
A reusable verification failure appeared inside the caller's run. The shared workflow could provide clear job and output names, but the repository owner needed enough context to act.
Error messages named the violated contract, expected input, observed safe summary, and workflow revision. They linked to versioned documentation rather than a mutable troubleshooting page alone.
The shared layer did not auto-open changes or mutate caller files. A migration helper could propose the required update, but the caller adopted it deliberately.
Operational events recorded both caller revision and shared workflow revision. If several repositories failed after one shared update, the common identity made correlation immediate.
Central ownership did not mean distant diagnosis. Evidence traveled back to the place where the run and product context lived.
The best shared failure was specific enough that the caller did not need to read the central YAML first.
After the first success, I tried to move browser tests, database setup, container publication, and deployment into the shared workflow. Inputs multiplied. Branches encoded repository personalities.
I stopped when a shared change required understanding one caller's unique architecture to assess another caller's safety.
The final boundary shared:
- Runtime and lockfile verification patterns.
- Manifest schema checks.
- Artifact retention and digest outputs.
- A narrow publishing protocol for compatible targets.
Repositories kept unique database, browser, migration, and rollout logic. Some duplicated lines remained because their meanings differed.
DRY is not a goal stronger than local comprehensibility. A reusable workflow should encode one stable common contract, not conceal several unrelated procedures behind flags.
The shared layer became smaller after I understood it better.
Adoption stayed observable
The central repository tracked supported versions and known callers in the personal project set. Each caller's manifest recorded its pinned version. A check could report outdated or unsupported references without changing them.
I distinguished:
- Latest available.
- Recommended after fixture validation.
- Minimum supported for security or platform compatibility.
- Deprecated with an end date.
Notifications grouped meaningful changes rather than announcing every central commit. An urgent unsupported version named the risk and required action.
There was no invented organization-wide policy or required-workflow feature. These were a few personal repositories voluntarily calling shared automation available in late 2021.
The small inventory was enough to prevent reuse from becoming invisible infrastructure.
The first version respected the 2021 surface
Reusable workflows had entered public beta in October and became generally available on November 24. I kept the December experiment within what existed then: a caller invoking a whole reusable workflow through workflow_call, with declared inputs, secrets, and outputs.
I did not design around later nesting, matrix-call, local-reference, or organization-required-workflow capabilities. Where composition was unavailable or awkward, repository scripts and separate caller jobs remained the boundary.
This mattered beyond historical accuracy. Early feature constraints shaped the architecture. One shallow shared call encouraged a focused contract. The absence of central enforcement meant each personal repository visibly opted in and owned its pinned reference.
Future improvements could reduce ceremony and expand composition. They would receive their own review rather than being written backward into the first adoption.
Centralization amplified judgment
Reusable workflows solved the drift that motivated them. Runtime setup, manifest verification, and artifact receipts became consistent. Fixes could be implemented once and adopted through versioned references.
The feature also made every shared decision more consequential. A floating reference, broad secret, raw command input, or mutable default could spread faster than copied YAML ever had.
I treated the workflow as a versioned API and dependency. Callers kept event and product-specific authority. Secrets were explicit and narrow. Outputs were receipts. Rollouts used compatibility fixtures and deliberate adoption.
Reuse was valuable not because it removed all repetition. It created one place where a genuinely common assumption could be made visible, tested, and improved.
The danger was the same property. One place can preserve a good invariant—or distribute a bad assumption perfectly.