DevOps engineer reviewing GitHub Actions build logs and Linux file permissions on a laptop.

Keep Executable Bits When GitHub Actions Moves Your Build

Thu, Oct 8, 2026

In a GitHub Actions pipeline, a build job may produce an executable script and upload it as an artifact, only to discover that the consumer job’s copy has the right bytes but the script no longer runs. In this synthetic scenario, the downloaded files’ SHA-256 hashes match the approved payload, yet attempting ./bin/probe.sh fails with “Permission denied.” The missing executable bit breaks the expected behavior. We must therefore decide if the artifact satisfies the filesystem contract that the consumer requires, or if it must be repackaged.

This playbook assumes an owned GitHub.com repository and GitHub-hosted Linux runners selected with runs-on: ubuntu-24.04. Python 3.12.x and GNU tar 1.35 are the reference tool versions; record the actual runner image, kernel and installed versions when executing. Keep local fixture checks separate from the intended hosted run. All GitHub Actions outcomes and runner listings below are expected examples until actual workflow logs have been captured.

This analysis focuses solely on file modes and contents, not on who authored or signed the artifact. We treat provenance and repository permissions as separate concerns (see consumer provenance and release approval for origin attestation policies). Our goal is a clear pass/fail on the approved payload contract: exact paths, file types, contents, and POSIX mode bits. A supplemental “probe” (the shell script) can confirm execution, but it is not a substitute for a full manifest check. We will observe that zipped uploads normalize permissions, whereas a tar can preserve them. We end with a decision: either Accept the artifact package as meeting the contract, Repackage it to correct the modes, or Hold it for missing or conflicting evidence.

Define the Filesystem Contract Your Consumer Needs

A consumer’s acceptance criterion is a strict filesystem manifest: the exact set of relative paths with their entry types (file or directory), the approved file contents (tracked via SHA-256 hashes), and the approved POSIX mode bits for each entry. Before comparing any content or mode, the consumer must verify the path set: no file or directory is missing or extra. Any unsupported type (symlink, device, etc.) should be flagged as invalid. For example, an approved manifest for our payload might include “bin/probe.sh” as a regular file with mode 0755, “docs/readme.txt” as a file with mode 0644, and “private” as a directory 0700 containing “flags.txt” mode 0600. The consumer’s verifier should never derive expected values from the downloaded artifact itself; instead, it should use an independently approved source of truth (e.g. hardcoded constants or a signed manifest).

In practical terms, our verifier script uses Python’s lstat and stat.S_IMODE() to read each file’s mode, and hashlib.sha256() to compute each file’s digest. It rejects any path not in the manifest, any content hash that doesn’t match the pre-computed value, and any mode mismatch. Only if all expected paths match in both content and mode would we consider the payload correct. We then use the probe script to test execution. We define three outcomes:

•   Accept: The downloaded artifact exactly matches the manifest (all digests and modes) and the probe runs successfully (exit code 0 and expected output). The artifact’s packaging is acceptable.

•   Repackage: The file contents match the manifest (matching digests) but some mode bits do not. The artifact must be repackaged (e.g. using a different format or flags that preserve modes) and re-supplied.

•   Hold: The artifact has a substantive mismatch (wrong contents, missing/extra paths, or an execution failure not explained by mode alone). The artifact is not safe to accept without investigation.

Note that GitHub token permissions and repository authorities are outside this contract. This is purely a filesystem property check. (For example, even if a token lacked write permissions, file modes still have their usual POSIX meaning.) Origin trust (repository/workflow approval) is handled separately as in the consumer provenance and release approval discussion.

Separate Content Identity From Permission Bits

To illustrate why content and mode must be checked separately, consider one file row of our approved manifest vs. observed state:

Path

SHA-256 Digest (content)

Expected Mode

Observed Mode

bin/probe.sh

a71b181e1586d97181f1a8ca07b99f41…40

0755

0644

docs/readme.txt

b5933275ffe46af478a95be700131ee5…a94b

0644

0644

private/flags.txt

84fde793460c5d8ebfee5d9cfa65757a…c9c

0600

0644

In this example, the content digests (shown truncated) still match the approved values for all files, so a content-only check would pass. However, bin/probe.sh lost its execute bits (0755→0644), and private’s contents lost the private modes. Because a SHA-256 hash only covers file bytes (via hashlib.sha256().update(data)), it cannot detect a mode-only change. Python’s stat.S_IMODE(st_mode) function must be used to extract the permission bits from an lstat() result. Likewise, GitHub’s artifact digest (the artifact-digest output) ensures the uploaded archive’s bytes transferred intact, but it does not attest to the extracted file modes. A content hash does not cover the full contract. A matching archive digest means “artifact bytes identical” but not “filesystem items identical.” Only by separately checking each file’s mode do we catch that discrepancy.

Pin the Producer and Consumer Environment

We implement this test in a single workflow with two jobs: a producer and a consumer (consumer has needs: producer). Both use runs-on: ubuntu-24.04. Record the actual kernel and installed Python and GNU tar versions; a hosted image label does not fix a particular Linux 6.17.x kernel build or package patch version. The examples use Python 3.12.x and GNU tar 1.35.

For reproducibility, the example action versions are actions/[email protected] and actions/[email protected]. The upload v7.0.1 release corresponds to commit 043fb46d1a93c77aae656e7c1c64a875d1fc6a0a; record the resolved commits in the execution evidence. We explicitly set digest-mismatch: error on the download step. The v8 download action documents error as the default, while GitHub’s artifact-validation tutorial describes digest mismatches as warnings. Use the pinned action’s documented policy for this test.

We also ensure each step uses separate paths: zip-output/ for the unzipped payload and archive-output/ for the tar. Record the effective user and umask rather than assuming the typical runner user and 0022 mask. On hosted runners, the workspace is commonly /home/runner/work/<repo>/<repo>. Each hosted job uses a fresh VM to avoid state leakage. If adapting the workflow to a self-hosted fleet, consult GitHub Actions runner lifecycle guidance for runner support and image management. The workflow first uploads artifacts, then downloads into a clean directory, so there is no interference from previous runs.

Build a Small Payload With Different Mode Requirements

We use a minimal payload with three files and three dirs, each with explicit modes:

FILES = {
    "bin/probe.sh": (
        0o755,
        b"#!/bin/sh\nprintf 'REFONTE_MODE_PROBE_OK\\n'\n",
    ),
    "docs/readme.txt": (0o644, b"public fixture notes\n"),
    "private/flags.txt": (0o600, b"synthetic flags only\n"),
}
DIRS = {"bin": 0o755, "docs": 0o755, "private": 0o700}

In the producer job we create a directory payload/ and, for each entry above, write the file bytes and then use os.chmod() (or chmod shell) to set the given mode. This is scripted or done with Python to ensure the exact bytes and modes. As a sanity check, we independently compute the SHA-256 of each file’s contents (e.g. via hashlib.sha256) and record them; they are:

Path

Expected SHA-256 (hex)

bin/probe.sh

a71b181e1586d97181f1a8ca07b99f41c1f8074d787edb36a7cf2439a96e5f40

docs/readme.txt

b5933275ffe46af478a95be700131ee5aaf513d99a8737b7dda3d5d85723a94b

private/flags.txt

84fde793460c5d8ebfee5d9cfa65757ad29424b37dabed3c40bc5d89b5088c9c

These expected hashes (computed locally before upload) serve as constants in our verifier. The producer should verify they are correct against the source bytes (for example, by running a small Python snippet) before uploading. Note that the verifier itself is kept outside the payload/ directory to avoid any accidental tampering by the download step.

Approve the Manifest Before the Upload

Before uploading, the producer job runs a verifier to confirm payload/ exactly matches the approved manifest. Save the following as verify.py outside the payload directory. Run python verify.py payload for the producer, or supply a different root for a downloaded candidate. If no argument is supplied, the verifier checks payload. The fixture is an owned, quiescent directory tree; the verifier is not a sandbox for a concurrently modified or hostile filesystem.

import hashlib
import os
import stat
import sys
from pathlib import Path

# Approved manifest, maintained independently of downloaded files.
FILES = {
    "bin/probe.sh": (
        0o755,
        "a71b181e1586d97181f1a8ca07b99f41c1f8074d787edb36a7cf2439a96e5f40",
    ),
    "docs/readme.txt": (
        0o644,
        "b5933275ffe46af478a95be700131ee5aaf513d99a8737b7dda3d5d85723a94b",
    ),
    "private/flags.txt": (
        0o600,
        "84fde793460c5d8ebfee5d9cfa65757ad29424b37dabed3c40bc5d89b5088c9c",
    ),
}
DIRS = {"bin": 0o755, "docs": 0o755, "private": 0o700}


def hold(message):
    raise SystemExit(f"Hold: {message}")


if len(sys.argv) > 2:
    hold("Usage: python verify.py [payload-directory]")
root = Path(sys.argv[1] if len(sys.argv) == 2 else "payload")
if not stat.S_ISDIR(root.lstat().st_mode):
    hold("payload root must be a real directory")

# Inventory without following symlinks; reject unsupported entry types.
actual = {}
for directory, subdirs, files in os.walk(root, followlinks=False):
    for name in subdirs + files:
        member = Path(directory) / name
        rel = member.relative_to(root).as_posix()
        info = member.lstat()
        if not (stat.S_ISREG(info.st_mode) or stat.S_ISDIR(info.st_mode)):
            hold(f"unsupported entry type at {rel}")
        actual[rel] = info

expected = set(FILES) | set(DIRS)
if set(actual) != expected:
    missing = sorted(expected - set(actual))
    extra = sorted(set(actual) - expected)
    hold(f"path set mismatch: missing={missing}, extra={extra}")

# Check all bytes and types before classifying mode-only failures.
mode_errors = []
for path, (exp_mode, exp_hash) in FILES.items():
    info = actual[path]
    if not stat.S_ISREG(info.st_mode):
        hold(f"expected regular file at {path}")
    digest = hashlib.sha256((root / path).read_bytes()).hexdigest()
    if digest != exp_hash:
        hold(f"content hash mismatch for {path}")
    mode = stat.S_IMODE(info.st_mode)
    if mode != exp_mode:
        mode_errors.append(
            f"{path}: expected {oct(exp_mode)}, got {oct(mode)}"
        )

for path, exp_mode in DIRS.items():
    info = actual[path]
    if not stat.S_ISDIR(info.st_mode):
        hold(f"expected directory at {path}")
    mode = stat.S_IMODE(info.st_mode)
    if mode != exp_mode:
        mode_errors.append(
            f"{path}: expected {oct(exp_mode)}, got {oct(mode)}"
        )

if mode_errors:
    raise SystemExit("Repackage: " + "; ".join(mode_errors))
print("Manifest matches; run the approved probe.")

This script aborts if any path is missing or extra, an entry has an unsupported type, a content hash is wrong, or any mode differs. It checks the complete path set before hashes and checks all file contents before classifying mode-only failures as Repackage. It demonstrates how the stat module and hashlib enforce the manifest; the logic is explicit. If it passes, the payload matches the approved manifest and the producer can proceed to upload. Full consumer acceptance still requires the approved probe to succeed.

Transfer the Directory Through the Zipped Route

The producer job then uploads the payload/ directory as a ZIP-based artifact. For example, in YAML:

steps:
  - uses: actions/checkout@v3
  - name: Upload zipped artifact
    id: upload
    uses: actions/[email protected]
    with:
      name: mode-zip
      path: payload/
      archive: true
      if-no-files-found: error

The artifact is named mode-zip. This step will output an artifact-id and an artifact-digest (the SHA-256 of the uploaded content) which could be logged. Next, the consumer job downloads it:

needs: producer
steps:
  - name: Download zipped artifact
    uses: actions/[email protected]
    with:
      name: mode-zip
      path: zip-output/
      digest-mismatch: error

We set digest-mismatch: error explicitly, matching the v8 digest mismatch policy, to fail if the downloaded artifact’s digest differs from the server’s expected digest. This populates zip-output/ with the artifact’s contents. Because archive: true was used, the bin, docs and private directories from payload/ are recreated directly inside zip-output/, without an additional payload/ subfolder. We do not use a name: parameter for the tar upload below, since the single-file archive: false route uses the filename.

Inspect the Download Before Trying to Repair It

After download, we examine zip-output/ to see what modes came through. GitHub’s upload documentation describes permission normalization for zipped artifacts, and its download documentation confirms the resulting file modes: directories become rwxr-xr-x (0755), and files become rw-r--r-- (0644). The following expected listing illustrates these modes. Timestamps, owner names, directory sizes and directory link counts depend on the runner:

$ ls -ld zip-output
drwxr-xr-x 5 runner runner 4096 Oct 7 10:00 zip-output
$ ls -l zip-output
drwxr-xr-x 2 runner runner 4096 Oct 7 10:00 bin
drwxr-xr-x 2 runner runner 4096 Oct 7 10:00 docs
drwxr-xr-x 2 runner runner 4096 Oct 7 10:00 private

$ ls -l zip-output/bin
-rw-r--r-- 1 runner runner   43 Oct 7 10:00 probe.sh

$ ls -l zip-output/private
-rw-r--r-- 1 runner runner   21 Oct 7 10:00 flags.txt

This output shows that all directories are mode 0755 and all files 0644, regardless of our original payload/ modes. We see mismatches with the approved manifest: bin/probe.sh is 0644 (should be 0755), private directory is 0755 (should be 0700), and private/flags.txt is 0644 (should be 0600). The file contents themselves still match (we could rerun the same SHA check to confirm).

We then attempt the probe script as-is (without altering permissions). Importantly, we do not run sh probe.sh (which would execute it even if it lacks +x). Instead:

$ ./zip-output/bin/probe.sh
bash: ./zip-output/bin/probe.sh: Permission denied

As expected, the script is not executable, so it fails immediately. We log this result. This confirms that the payload’s content was intact but the executable bit is gone. Because this was a known mode mismatch, our consumer logic would mark this artifact Repackage (not Accept). We do not silently ignore this error; we let the job step fail or record it. In CI, we would allow this to propagate as a failure (see preserving failure status through CI logging for how to capture such failures). In other words, even though the download step was “successful” from the transport viewpoint, the artifact does not meet the acceptance criteria.

Distinguish a Successful Download From an Accepted Payload

It is crucial to separate artifact transfer from contract acceptance. The download step returning zero only means the ZIP was fetched and extracted. The next stages (comparing digests, checking modes and running probes) determine if we truly accept the artifact. We do not use continue-on-error to mask a bad manifest. Instead, we capture the mismatches and set our overall status accordingly. For example, the probe failure above yields a nonzero exit code, which we allow to fail the job or at least mark as a “Repackage needed” status. We log the evidence (the wrong modes and the exact error message) so we can prove to an auditor or the release owner why the artifact was rejected.

Show Why One chmod Can Leave the Contract Broken

Suppose as a quick fix, the consumer tries to salvage the ZIP output by only making the script executable. On a copy of the downloaded tree:

$ cp -r zip-output zip-output2
$ chmod 755 zip-output2/bin/probe.sh
$ ls -l zip-output2/bin/probe.sh
-rwxr-xr-x 1 runner runner 43 Oct 7 10:00 probe.sh

$ ./zip-output2/bin/probe.sh
REFONTE_MODE_PROBE_OK

Now bin/probe.sh runs successfully. However, note the remaining state:

$ ls -ld zip-output2/private
drwxr-xr-x 2 runner runner 4096 Oct 7 10:00 private
$ ls -l zip-output2/private/flags.txt
-rw-r--r-- 1 runner runner 21 Oct 7 10:00 flags.txt

The private directory is still 0755 and flags.txt is 0644. The manifest still does not match (two modes are wrong). Thus the overall payload is still incorrect. We might label this “Repackage” as well: the artifact was changed locally, but it still did not meet the contract. This demonstrates that a single chmod +x is not a complete solution; we would have to chmod every mismatched entry, which risks mistakes. It’s better for the producer to package correctly up front. (We do not recommend that consumers arbitrarily chmod -R downloaded content in production; the expected modes should come from a secure source, not guessed from file extensions.) A repackage with the correct format solves the problem systematically.

Pack the Approved Modes Into a Tar File

To preserve permissions in transit, we repack the original payload as a single tar file, including the directory entries themselves:

tar --create --file bundle.tar --directory payload bin docs private
tar --list --verbose --file bundle.tar

The verbose listing of the tar archive shows the stored modes:

drwxr-xr-x runner/runner       0 Oct 7 12:00 bin/
-rwxr-xr-x runner/runner      43 Oct 7 12:00 bin/probe.sh
drwxr-xr-x runner/runner       0 Oct 7 12:00 docs/
-rw-r--r-- runner/runner      21 Oct 7 12:00 docs/readme.txt
drwx------ runner/runner       0 Oct 7 12:00 private/
-rw------- runner/runner      21 Oct 7 12:00 private/flags.txt

Here we see private/ stored as mode 0700 (drwx------) and flags.txt as 0600 (-rw-------), while the script still has -rwxr-xr-x. We then upload only bundle.tar as an artifact:

- name: Upload tar artifact
  uses: actions/[email protected]
  with:
    path: bundle.tar
    archive: false
    if-no-files-found: error

We omit name: because for a single-file upload, the artifact name defaults to the filename (bundle.tar). We record the tar’s digest (separately from the inner-file hashes), which the download step will check. (For example, artifact-digest might be printed.) This tar is our new candidate payload. We treat it as an opaque blob for transport, containing all modes. (The “partial extraction after a tarfile refusal” scenario is unrelated; we are not filtering any content from the tar in this test.)

Keep the Archive Representation and Its Members Separate

It is important to note that the tar archive itself has an outer filename and mode, which we do not care about. What matters is its contents. The bundle.tar file on disk might be 0644 (typical), but inside it holds the member files with their modes, as seen above. The act of creating and uploading the tar is separate from the act of extracting it later. We cannot conclude anything about the final filesystem from the tar’s exit code or listing alone. Both transport and extraction matter for the proof.

Download and Extract the Tar With an Explicit Mode Policy

The consumer job downloads the bundle.tar artifact and then extracts it under controlled conditions:

- name: Download tar artifact
  uses: actions/[email protected]
  with:
    name: bundle.tar
    path: archive-output
    digest-mismatch: error

- name: Extract tar with permissions
  run: |
    mkdir restored-output
    tar --extract --file archive-output/bundle.tar \
        --directory restored-output --same-permissions --no-same-owner

We use tar’s --same-permissions option (also called -p) so that tar applies the archived modes without restricting them through the umask. For a non-root extractor, GNU tar normally removes permission bits selected by the current umask. A umask removes permissions; it cannot turn 0700 into 0755. With a 0022 umask, the fixture’s archived 0700 and 0600 modes are not broadened. The explicit option makes the intended extraction policy reviewable. We also use --no-same-owner to avoid restoring archived user and group ownership on the runner. After extraction, restored-output/ should contain the payload tree with the exact approved modes.

Choose How Extraction Applies Archived Modes

Using --same-permissions means that restored-output/private is restored as 0700 and restored-output/private/flags.txt as 0600, exactly as archived. Without that flag, an ordinary user’s extraction is subject to the umask: under 0077, archived 0755 entries can become 0700 and 0644 files can become 0600. Root normally preserves the archived permissions already. Stating --same-permissions makes the intended policy explicit across these environments. We make no claim about ownership beyond the extraction step.

To verify, we can list the extracted tree:

$ ls -ld restored-output/{bin,docs,private}
drwxr-xr-x 2 runner runner 4096 Oct 7 10:05 restored-output/bin
drwxr-xr-x 2 runner runner 4096 Oct 7 10:05 restored-output/docs
drwx------ 2 runner runner 4096 Oct 7 10:05 restored-output/private

$ ls -l restored-output/bin
-rwxr-xr-x 1 runner runner 43 Oct 7 10:05 probe.sh

$ ls -l restored-output/private
-rw------- 1 runner runner 21 Oct 7 10:05 flags.txt

$ ls -l restored-output/docs
-rw-r--r-- 1 runner runner 21 Oct 7 10:05 readme.txt

These listings confirm all modes match the approved manifest: directories bin and docs are 0755 (as intended), private is 0700, probe.sh is 0755, readme.txt is 0644, and flags.txt is 0600.

Reconcile Every Member After Materialization

We now run python verify.py restored-output, using the same verifier as on the producer side. It checks the complete path set, each file’s content hash, each entry’s type and each mode. If all agree with the manifest, the verifier exits cleanly. Finally, execute the probe:

$ ./restored-output/bin/probe.sh
REFONTE_MODE_PROBE_OK

The script prints the sentinel string and returns exit code 0. Thus every check has passed. We would mark this candidate Accept, because it fully matches the contract and the probe succeeded.

For clarity, here is a compact comparison summary:

Scenario

Content match?

Mode match?

Probe result

Action

Original payload

Yes

Yes (verified)

Not run at this stage

Approved

Zipped download

Yes

No (probe.sh, private directory and flags.txt)

Fails: permission denied

Repackage

chmod fix only

Yes

No (private 0755; flags.txt 0644)

Passes: probe OK

Repackage

Tar restored

Yes

Yes (all modes)

Passes: probe OK

Accept

Each failed result indicates a detected issue. Even after the chmod-only fix, both the private directory and its flags.txt file retain incorrect modes, so that candidate is still rejected. Only the tar-based package is expected to meet the complete contract; confirm it with the consumer’s own evidence.

Prove That the Verifier Rejects an Extra Member

As a final negative control, we show that any stray file causes rejection. Suppose after extraction we create an unexpected file:

$ cp -r restored-output extra-test
$ touch extra-test/unapproved.txt
$ python verify.py extra-test
Hold: path set mismatch: missing=[], extra=['unapproved.txt']

Our manifest check compares path sets first, so it immediately flags the extra file. Even though all official files would match, the extra entry triggers a Hold. This demonstrates that the verifier will never silently ignore extra or missing items; the path-set comparison is mandatory.

Preserve Failure Evidence and Return a Useful Status

In a real workflow, we gather all the above information into logs or an artifact report. We record the job/run IDs, action versions, and all mismatch details: which paths failed, what modes and hashes were expected vs. found, and the probe’s exit code and output. Typically, the job would write a summary report (e.g. as JSON or plaintext) and set an exit status to reflect the outcome. Any failure during download, extraction, inventory check, or probe execution should result in a nonzero job exit so that the pipeline is marked failed (see preserving failure status through CI logging for techniques). We do not rely on any stale “PASS” token from a previous run; each run’s status is freshly determined. For example, we might have logic like: if any manifest mismatch or probe failure occurred, set RESULT=Repackage or Hold and then exit 1. Conversely, an all-OK run would exit 0. This ensures that a passed check truly means acceptance, and a failed check leaves evidence for debugging.

Repackage From Approved Source and Repeat the Consumer Check

Once the tar-based package is accepted, we could publish it or share it with the release stage. The original ZIP-based artifact (and all its logs) remain archived as evidence of the problem. We do not delete or overwrite it automatically. If for some reason we needed to produce yet another candidate (e.g. the modes were updated in the manifest), we would repeat the packaging process from the trusted source. In any case, the existing repository of known-good artifacts is left intact, and the new artifact is treated as a separate candidate. We do not implement an automatic pointer switch or hotfix; that is beyond this scope. If the expected modes were not known (for example, if the contract changed), we would put the build on Hold and require a new, explicit manifest version.

Apply a Decision Matrix for Packaging Changes

Summarizing the above into a quick-reference table:

Condition

Packaging Decision

Correct content & modes, probe succeeds

Accept

Correct content, wrong POSIX modes (e.g. no +x)

Repackage

Any file’s content mismatch (hash fail)

Hold

Missing or extra path entries

Hold

Artifact digest mismatches on download

Hold

Unsupported entry types (symlinks, devices, etc.)

Hold

Correct manifest but probe execution fails

Investigate/Hold

Incomplete data (download or extraction error)

Hold

This table assumes actions/download-artifact@v8 with digest-mismatch: error, so digest failures stop the run. GitHub’s artifact-validation tutorial describes digest mismatches as warnings, but the pinned v8 action’s explicit error policy governs this test. The “probe fails” case might occur if the script’s shebang is incompatible; that is a different boundary to debug. In each failing row, the appropriate action is to hold the artifact or repackage it according to the identified mismatch.

Assign Ownership to the Producer and Consumer

In practice, we assign roles in this process. The producer (build author) owns the artifact definition: they must commit to the approved bytes and modes (and ideally document them), and choose a packaging format (ZIP vs tar) that matches the contract. If the contract changes (e.g. a file becomes executable), the producer should update the manifest and regenerate artifacts, which the consumer will then re-validate. The consumer (release or downstream owner) owns the manifest verification: they check materialized output against the manifest, run the probe test, and decide Accept/Repackage/Hold. They also maintain the CI jobs and should ensure the verification code is up-to-date. The workflow maintainer (CI engineer) owns the environment specifics: pinning actions versions, capturing logs and digests, and ensuring runner compatibility. If any underlying tool changes (new action major version, different default umask on runners, etc.), the maintainer would update the test accordingly.

Importantly, this file-permissions contract is independent of GitHub access controls. Whether a workflow is allowed to run in a given repository, or which secrets it receives (as discussed in reusable-workflow secret and token boundaries), does not affect the meaning of a file’s execute bit. Those security boundaries protect different dimensions (who can build and who can invoke a workflow), while our mode contract is about how the built files appear in the filesystem. The reviewer of a contract change (e.g. changing a file from 0644 to 0755) is typically the same person/team that owns the release process, perhaps in coordination with security or product. Changes that affect the packaging (like switching from zip to tar) should be reviewed carefully.

Build the Linux and CI Skills Behind Reliable Delivery

The practices demonstrated here rely on solid Linux and CI/CD fundamentals, which are part of Refonte Learning’s DevOps Engineering curriculum. For example, knowing how to inspect file permissions (ls -l and stat), use Python for automation, and configure GitHub Actions workflows with recorded action versions and failure detection are core competencies for this playbook. Refonte’s DevOps Engineer Program is a 3-month course requiring approximately 12–14 hours per week, covering Linux fundamentals, scripting, Git/GitHub, CI/CD pipelines, Docker and Kubernetes. Successful completion earns a Training Certificate and a Certificate of Internship. If you found this artifact-testing approach useful, the program can help you develop the underlying Git and CI/CD reasoning.