Skip to content

Releases: nurkamol/seo-audit

1.38.2 — an extension on this decade's engine

Choose a tag to compare

@github-actions github-actions released this 30 Aug 11:38

Fixed

  • The Raycast extension was running an engine seven releases old. It
    declared ^1.31.0, and the score arrived in the engine at 1.34.0 — so the
    extension carried score UI the engine it depended on could never populate.
    Because the extension shares its library folder with the app and the command
    line, that showed up as an inconsistency rather than an absence: in Recent
    Reports
    a run crawled by the app had a score and a run crawled by the
    extension did not, in the same list.

    Now ^1.38.1, which also brings the site-wide cause collapse and the plural
    fixes. The lockfile had been held still while the Store submission was in
    review; that submission is a pull request in another repository and is not
    affected by this.

  • The Windows update dialog did not say to close the app first. It said
    running the new installer over this one was the whole update, which is true
    only once this one is closed: the installer offers to remove the old version
    before writing the new one, and an uninstaller cannot delete files that are
    open. It stops with "Unable to uninstall!".

    Reported by somebody updating 1.38.0, who had pressed Download in that
    very dialog and so still had the app running when the installer ran. The
    dialog had walked them into it.

  • The winget job failed a release it had no business failing.
    winget-releaser only ever updates a package: with nothing yet in
    microsoft/winget-pkgs it exits 1 with "Package Nurkamol.SeoAudit does not
    exist", which turned an otherwise-finished 1.38.1 red for a step that
    structurally could not succeed. It now asks that repository whether the
    package is there and warns instead — precisely, rather than with
    continue-on-error, so a real failure still fails once the package exists.

    The first version has to be submitted by hand, once. desktop/README.md has
    the komac command; a test checks that the section the warning names actually
    exists, because a pointer to a missing heading reads as an answer and ends
    the search.


Installing on macOS

brew tap nurkamol/seo-audit https://github.com/nurkamol/seo-audit
brew trust nurkamol/seo-audit
brew install --cask seo-audit

Homebrew verifies the download against the checksum below and clears the
quarantine flag for you, so the app opens the first time.

Downloading the zip instead? macOS may say "SEO Audit is damaged and
can't be opened."
It is not damaged. The app is ad-hoc signed rather than
notarised — notarising needs a paid Apple Developer account this project
does not have — and macOS refuses quarantined apps without a notarisation
ticket. Check what you downloaded first:

shasum -a 256 seo-audit-1.38.2-macos.zip
# 38e1565799a90243f94e61aef573f38b18c604c0ed4b855d5ffd0e9339bed009

Then, once it matches, clear the flag — no sudo needed:

xattr -dr com.apple.quarantine "/Applications/SEO Audit.app"

That is what right-click → Open does in the Finder, without the dialog.
Apple Silicon, macOS Tahoe or newer.

Windows and Linux

The setup.exe, .deb and .AppImage below are attached by a separate
job and may appear a few minutes after this release does.

The Windows installer is unsigned, so SmartScreen shows "Windows protected
your PC"
More info → Run anyway. Same reason as macOS: a code-signing
certificate is a paid, per-year thing this project does not have. The
.AppImage needs chmod +x before it will run.

Check any of them first. A SHA256SUMS.txt covering every file here is
attached alongside them, and the same checksums are listed at the end of
these notes:

shasum -a 256 --ignore-missing -c SHA256SUMS.txt

Checksums

Verify a download before you wave a warning away. With SHA256SUMS.txt
saved beside the file, in the same folder:

shasum -a 256 --ignore-missing -c SHA256SUMS.txt
7b02a22642af74cbfee6684323b75f1956461c5dbb534ccbedefdec76e13c2ac  SEO.Audit_1.38.2_amd64.AppImage
fdf68e5a65c8211f73bf73967d08613c21dba896855fd902980eff4719fe59e6  SEO.Audit_1.38.2_amd64.deb
106fc06001f780377d6e124646eec993cb0fb91a2aee9dfea757a60594283974  SEO.Audit_1.38.2_x64-setup.exe
38e1565799a90243f94e61aef573f38b18c604c0ed4b855d5ffd0e9339bed009  seo-audit-1.38.2-macos.zip

The macOS line appears twice in these notes on purpose: once above, computed
when the app was built, and once here, computed from what is actually attached.
They should agree.

1.38.1 — an updater that can actually update

Choose a tag to compare

@github-actions github-actions released this 29 Aug 15:24

Updating from 1.38.0 or earlier on macOS needs one command

This release fixes an updater that could not install its own fix. On 1.38.0
and earlier, Update offers this version and then does nothing: the banner
reads GitHub's releases while brew upgrade reads Homebrew's own copy of the
tap, which is refreshed at most once a day, so for a while after any release
the two disagree and Homebrew answers "the latest version is already
installed"
.

Once, in a terminal:

brew update && brew upgrade --cask seo-audit

From 1.38.1 onward the button does both steps itself, and this is the last
time it is needed.

Fixed

  • Update said a new version had appeared and then did nothing. The banner
    reads GitHub's releases; brew upgrade reads Homebrew's own clone of the tap,
    and that clone is refreshed by an auto-update which runs at most once a day.
    So for up to twenty-four hours after a release the two disagree: the window
    offers 1.38.0, Homebrew answers "Not upgrading seo-audit, the latest version
    is already installed"
    , and the process exits 0 — so nothing reported a
    failure and nothing moved.

    brew update now runs first, as a step rather than a hope, and the command
    shown on screen says both so that running it by hand does the same thing.
    Found by pressing Update on a real 1.36.0 with 1.38.0 published, watching it
    do nothing, and going after the reason.

  • Linux was told to run a command that could not work. An apt-installed
    copy was offered sudo apt-get install --only-upgrade seo-audit, and no apt
    repository anywhere carries this package — the .deb is downloaded from a
    release and installed by hand. apt would answer that it cannot locate it. The
    same failure as above in a different package manager: an instruction that
    reads like an answer and is not one. That copy now goes to the release page,
    where the next .deb actually is.

    With nothing left that could produce it, the "Show me" path and its dialog are
    gone. An option nothing can reach is an option that does not exist.

  • The winget path could never have worked, token or no token. Detection ran
    winget list --id Nurkamol.SeoAudit --exact and then asked whether the output
    contained seo-audit. It never does: winget prints an Id column reading
    Nurkamol.SeoAudit, the Name is SEO Audit, and the lowercase hyphenated
    spelling appears nowhere. So the check was false for every winget install
    there could ever be — and would have stayed false after a manifest shipped,
    with the branch dead for a reason nobody was looking at any more. It matches
    on the identifier now, case-insensitively, which is what winget is.

    The identifier is one constant rather than three copies, and the guard that
    keeps it in step with the workflow reads that constant and also fails if
    anybody writes it out by hand again.

  • The bundle declared no publisher, licence or description, all of which a
    winget manifest requires and all of which Windows shows in Add/Remove
    Programs. Set, so the first submission has something true to carry.


Installing on macOS

brew tap nurkamol/seo-audit https://github.com/nurkamol/seo-audit
brew trust nurkamol/seo-audit
brew install --cask seo-audit

Homebrew verifies the download against the checksum below and clears the
quarantine flag for you, so the app opens the first time.

Downloading the zip instead? macOS may say "SEO Audit is damaged and
can't be opened."
It is not damaged. The app is ad-hoc signed rather than
notarised — notarising needs a paid Apple Developer account this project
does not have — and macOS refuses quarantined apps without a notarisation
ticket. Check what you downloaded first:

shasum -a 256 seo-audit-1.38.1-macos.zip
# f1ce47dace1edecef143143f27e50f94dcd068b621e86affdc0ed39d4457eeb1

Then, once it matches, clear the flag — no sudo needed:

xattr -dr com.apple.quarantine "/Applications/SEO Audit.app"

That is what right-click → Open does in the Finder, without the dialog.
Apple Silicon, macOS Tahoe or newer.

Windows and Linux

The setup.exe, .deb and .AppImage below are attached by a separate
job and may appear a few minutes after this release does.

The Windows installer is unsigned, so SmartScreen shows "Windows protected
your PC"
More info → Run anyway. Same reason as macOS: a code-signing
certificate is a paid, per-year thing this project does not have. The
.AppImage needs chmod +x before it will run.

Check any of them first. A SHA256SUMS.txt covering every file here is
attached alongside them, and the same checksums are listed at the end of
these notes:

shasum -a 256 --ignore-missing -c SHA256SUMS.txt

Checksums

Verify a download before you wave a warning away. With SHA256SUMS.txt
saved beside the file, in the same folder:

shasum -a 256 --ignore-missing -c SHA256SUMS.txt
1328e19d7b4e3de68706ac978376b6b5f02a03182c2434e60a809b2671bdea17  SEO.Audit_1.38.1_amd64.AppImage
c147fe6865200efeff9454df5c792a4bc087f0a201ebc7d807a4309323ada651  SEO.Audit_1.38.1_amd64.deb
30fe3d86dee9b056638fa986387e9a1a0a20224a90764366a79aebde4c67d1af  SEO.Audit_1.38.1_x64-setup.exe
f1ce47dace1edecef143143f27e50f94dcd068b621e86affdc0ed39d4457eeb1  seo-audit-1.38.1-macos.zip

The macOS line appears twice in these notes on purpose: once above, computed
when the app was built, and once here, computed from what is actually attached.
They should agree.

1.38.0 — a Windows report that saves where you tell it

Choose a tag to compare

@github-actions github-actions released this 29 Aug 14:52

Added

  • The kept-runs list sorts by any of its columns, and remembers how you left
    it.
    There were no column headers at all: the list was a heading per site
    with its own small table under it, so the only order was newest-first and the
    only grouping was by host. It is one table now — Site, When, Pages, To
    change, Score — and sorting by Site is the grouping it used to have, as one
    of five orders rather than the only one.

    Links, not script: an order is a different view of the same list, so it is a
    different URL. It can be bookmarked, and it works before any JavaScript has
    run. Clicking the column you are already on turns it around; a new column
    opens the way that column is most useful, which is largest-first for dates
    and numbers and A-to-Z for a name.

    The filter and the order are kept in a cookie rather than in localStorage,
    for the same reason: a preference that only works once a script has run would
    be the one control on the page that does not. It comes back through the same
    validators the query string goes through — a cookie is a URL somebody kept,
    and it may have been edited since, so it names a column that exists or it is
    ignored.

    The score cell lost its /100. The column is headed Score; repeating the
    denominator on every row is the header said again, once per line.

  • The window takes its type and its accent from the machine it is on. The
    font stack stopped at "Segoe UI", which is Windows 10's face — Windows 11
    uses Segoe UI Variable Text — and offered Linux Roboto, an Android face few
    desktops have, ahead of Cantarell or Noto. And --accent was a hardcoded
    blue while every one of those systems lets a person pick their own; where the
    engine drawing this knows the answer, AccentColor is now that person's
    choice, AccentColorText is the colour guaranteed to be readable on it, and
    checkboxes and the date field take it too.

    The chrome only. A report is a document that gets sent to other people and
    keeps its own palette, so it looks the same on their machine as it did on
    yours.

    Keyboard focus is now one visible high-contrast ring rather than whatever the
    webview drew for each control type, and scrollbars take the page's colours
    instead of the light ones a dark window makes obvious.

    Not attempted: a design per operating system. "Native on Linux" is not one
    target — GNOME and KDE disagree — and a webview half-imitating WinUI lands in
    the uncanny valley, which reads as less finished rather than more. It would
    also split the browser and the app, which is the one thing this project
    refuses everywhere else.

Fixed

  • Save as … wrote straight to Downloads without asking, and said nothing
    afterwards.
    Reported by somebody using the Windows build, which is where the
    export links have always been plain downloads — the macOS app has its own save
    panel and never had this. The file now lands in a temporary directory, a
    native dialog asks where it should go, and a line in the bottom-left corner
    says where it went. Cancelling deletes the temporary copy, because a "Save
    as" that leaves a file behind after being cancelled is the same surprise
    wearing a different coat.

    The obvious implementation cannot be written: a dialog inside the download
    request, so the destination is chosen before any bytes move. That handler runs
    on the main thread, and the dialog plugin says in as many words that a
    blocking dialog there freezes the application.

  • "Open link in new window" did nothing. From the same report. A new-window
    request is not a navigation, so the handler that sends external links to the
    browser never saw it and nothing else was listening. It now goes the same way
    every other external link goes — to the browser, which has an address bar.

  • The kept runs were drawn with the webview's default table styling
    edge-to-edge columns and no separators, which is what a spreadsheet looks
    like and not what that page is. CHROME had no table rules at all: they were
    in the stylesheet page() serves, and /reports is drawn by shell(). The
    date now leads the row at full contrast because it is the link you aim at,
    the detail sits beside it muted, the score is right-aligned and tabular, and
    the filter is a secondary control so the one primary button on the page is
    the one that does something irreversible.

  • The kept-since filter shipped in 1.37.0 with no styling. Its CSS went into
    the stylesheet page() serves; /reports is drawn by shell(), which serves
    a different one. The markup was right, the class was right, and the rule
    simply never applied. That is twice now — the export links did it one release
    earlier — so a test now reads CHROME and fails when a class the served pages
    use is styled somewhere the served pages do not read.


Installing on macOS

brew tap nurkamol/seo-audit https://github.com/nurkamol/seo-audit
brew trust nurkamol/seo-audit
brew install --cask seo-audit

Homebrew verifies the download against the checksum below and clears the
quarantine flag for you, so the app opens the first time.

Downloading the zip instead? macOS may say "SEO Audit is damaged and
can't be opened."
It is not damaged. The app is ad-hoc signed rather than
notarised — notarising needs a paid Apple Developer account this project
does not have — and macOS refuses quarantined apps without a notarisation
ticket. Check what you downloaded first:

shasum -a 256 seo-audit-1.38.0-macos.zip
# 0b78d38c6029e58475566a12fd5d9b5ef78db752b0d95c5be07a32def1236c57

Then, once it matches, clear the flag — no sudo needed:

xattr -dr com.apple.quarantine "/Applications/SEO Audit.app"

That is what right-click → Open does in the Finder, without the dialog.
Apple Silicon, macOS Tahoe or newer.

Windows and Linux

The setup.exe, .deb and .AppImage below are attached by a separate
job and may appear a few minutes after this release does.

The Windows installer is unsigned, so SmartScreen shows "Windows protected
your PC"
More info → Run anyway. Same reason as macOS: a code-signing
certificate is a paid, per-year thing this project does not have. The
.AppImage needs chmod +x before it will run.

Check any of them first. A SHA256SUMS.txt covering every file here is
attached alongside them, and the same checksums are listed at the end of
these notes:

shasum -a 256 --ignore-missing -c SHA256SUMS.txt

Checksums

Verify a download before you wave a warning away. With SHA256SUMS.txt
saved beside the file, in the same folder:

shasum -a 256 --ignore-missing -c SHA256SUMS.txt
d42cab61b85d00490d8d1b0827528ba0d3b8a53c3a6d5c0698bd6d1d223b05a8  SEO.Audit_1.38.0_amd64.AppImage
d491b1dba03015828095e54bfb186395a5deccddbd2c0a67c0db19cfd57c3176  SEO.Audit_1.38.0_amd64.deb
2c0eb1120bb14a216eba50dbfb37a562f08beeb3ebb5a6a518fdcb48b2e6191f  SEO.Audit_1.38.0_x64-setup.exe
0b78d38c6029e58475566a12fd5d9b5ef78db752b0d95c5be07a32def1236c57  seo-audit-1.38.0-macos.zip

The macOS line appears twice in these notes on purpose: once above, computed
when the app was built, and once here, computed from what is actually attached.
They should agree.

1.37.0 — a report that says a site-wide problem once

Choose a tag to compare

@github-actions github-actions released this 29 Aug 12:22

Added

  • The Raycast extension can ask Search Console. The engine has had
    --search-console since 1.21.0 and the extension never exposed it — a
    launcher showing every finding except the one number that is not a proxy for
    attention. A checkbox switches it on and a field names the property, the same
    shape the flag has: bare means the site being crawled, a value names a
    property.

    It does not ask for credentials. Getting them opens a browser and writes a
    file, which is a terminal errand rather than a control in a window — the same
    answer the macOS app gives. Without them the engine reports
    search-console-unconfigured and names what is missing, so this fails in the
    report rather than going quiet.

    Checked in both directions, because both failures are silent: a preference
    the code reads and the manifest never declares is a control nobody can reach,
    and one declared but never read is a control that does nothing.

  • --reports lists the runs kept on this machine, and --reports 2026-08-01 lists what was kept since. The library was reachable from the
    macOS window and the served list and from nowhere on the command line, which
    is the one place the rest of this tool lives. /reports gains the same filter
    as a date control, and both call one function so the browser and the terminal
    cannot disagree about what "since" means.

    That function is not in src/library.mjs. That module opens files, so it
    imports node:fs, and the Worker showing the same list has no filesystem and
    would not have survived the import — src/kept.mjs is the pure half both
    sides need.

    A date it cannot read is refused rather than ignored, because listing every
    run when somebody asked for one week looks like an answer. A date that hides
    everything says how many it hid, since an empty list and an empty library read
    identically and only one of them means "widen the date".

  • A check that fires on every page is one thing to change, not one per
    section.
    Grouping by URL prefix is right when a check hits part of a site —
    that is the Shopify /products/ insight this project's grouping was built on
    — and wrong when it hits all of it. gohugo.io produced 200 things to change
    and 104 of them were four checks that each applied to all 150 pages
    ;
    canonical-missing alone arrived as twenty-six separate pieces of work, one
    per prefix, making the reader do the arithmetic that tells them it was
    site-wide. The same report is now 100 things to change, and those four read
    every page crawled (150), 1,695 links in.

    Exactly every page, never nearly. On 149 of 150 "every page" is a false
    sentence, and a report is only worth reading because its sentences are true.
    It costs no extra requests — the grouping was already computed.

Fixed

  • "1 image(s) without width/height" was the most repeated finding title in a
    real report and read like a form nobody finished. Fifty of these across seven
    files now pick the right word. Zero is plural, which is the case that gets
    written wrong by reaching for n > 1.

  • "1 links in" — the same defect one line further on, hardcoded rather than
    spelled (s), so no search for the first would have found it. It had been
    shipping for as long as reach has been reported, and no fixture ever had
    exactly one inbound link. Found by running the tool against a real site and
    reading the output, which is the only way anything here has ever been found.

  • A PDF's section headings could be left alone at the foot of a page, with
    the findings they introduce overleaf. A cause block listing every affected
    page can be most of a page tall, so a heading placed just before one had
    nowhere to go — the packer was greedy and had no keep-with-next rule. Three
    of the 53 pages in a gohugo.io export ended that way: Content 79,
    Indexability 26, Images 52.

    A heading now travels with the block it introduces. PDF.paginate came out
    of write to make it testable without a renderer, because the bug is
    arithmetic rather than drawing — including the cases that must not move a
    heading, and the one where a page holds nothing but headings and must not be
    emitted blank.

    It does not make the document shorter, and the page count is unchanged at 53.
    The white space above a page break comes from cause blocks being atomic and
    nearly page-tall; only splitting them would reclaim it, and a finding split
    across a page break is worse than a short page.


Installing on macOS

brew tap nurkamol/seo-audit https://github.com/nurkamol/seo-audit
brew trust nurkamol/seo-audit
brew install --cask seo-audit

Homebrew verifies the download against the checksum below and clears the
quarantine flag for you, so the app opens the first time.

Downloading the zip instead? macOS may say "SEO Audit is damaged and
can't be opened."
It is not damaged. The app is ad-hoc signed rather than
notarised — notarising needs a paid Apple Developer account this project
does not have — and macOS refuses quarantined apps without a notarisation
ticket. Check what you downloaded first:

shasum -a 256 seo-audit-1.37.0-macos.zip
# a350802df8f5f4bdf808909aab22a88d5313d1b3ffd50c543f41e8d8545fc2bb

Then, once it matches, clear the flag — no sudo needed:

xattr -dr com.apple.quarantine "/Applications/SEO Audit.app"

That is what right-click → Open does in the Finder, without the dialog.
Apple Silicon, macOS Tahoe or newer.

Windows and Linux

The setup.exe, .deb and .AppImage below are attached by a separate
job and may appear a few minutes after this release does.

The Windows installer is unsigned, so SmartScreen shows "Windows protected
your PC"
More info → Run anyway. Same reason as macOS: a code-signing
certificate is a paid, per-year thing this project does not have. The
.AppImage needs chmod +x before it will run.

Check any of them first. A SHA256SUMS.txt covering every file here is
attached alongside them, and the same checksums are listed at the end of
these notes:

shasum -a 256 --ignore-missing -c SHA256SUMS.txt

Checksums

Verify a download before you wave a warning away. With SHA256SUMS.txt
saved beside the file, in the same folder:

shasum -a 256 --ignore-missing -c SHA256SUMS.txt
8e4e289a09f72481a476afcd194af08395b22966f801510db8a75fd5f70e3fd2  SEO.Audit_1.37.0_amd64.AppImage
649b24e6c2434637061bc6e103f4c85c8f067da3bdad33f94baa29f12cbb1bcd  SEO.Audit_1.37.0_amd64.deb
a8b061f31ffd6f39a4492de1ab6d4a0dcbd882491eb44044a50836ab8691a8e4  SEO.Audit_1.37.0_x64-setup.exe
a350802df8f5f4bdf808909aab22a88d5313d1b3ffd50c543f41e8d8545fc2bb  seo-audit-1.37.0-macos.zip

The macOS line appears twice in these notes on purpose: once above, computed
when the app was built, and once here, computed from what is actually attached.
They should agree.

1.36.0 — checksums, and screenshots that are real

Choose a tag to compare

@github-actions github-actions released this 29 Aug 10:59

Added

  • Every release now says how to install it, on the page where it is
    downloaded.
    The macOS app is ad-hoc signed rather than notarised — that
    needs a paid Apple Developer account this project does not have — so macOS
    quarantines the downloaded zip and Gatekeeper refuses it with "SEO Audit is
    damaged and can't be opened"
    , offering to move it to the Trash. It is not
    damaged, and that message reads exactly like malware.

    Homebrew never had this problem: the cask verifies the checksum and clears the
    flag. But the release page and the README's download badge both led somewhere
    that explained none of it. Both now do, with the checksum printed first, so
    clearing the flag is something done after verifying the file rather than on
    the say-so of a README.

    Windows gets the same treatment for SmartScreen, and the AppImage's chmod +x
    is written down rather than assumed.

  • Every file on a release has a published checksum. The macOS zip always had
    one, because the Homebrew cask needs it. The .deb, the .AppImage and the
    setup.exe had none — so "check it before you wave the warning away" was
    advice you could not actually follow on the two platforms whose warning
    dialogs are the reason to say it. On Windows it reduced to "run it anyway".

    A SHA256SUMS.txt is attached to each release and the same lines are printed
    in its notes, so one command covers all four:

    shasum -a 256 --ignore-missing -c SHA256SUMS.txt

    Hashed from what is actually attached rather than from what each runner built,
    which also proves the upload arrived intact. One job rather than a step in each
    build: two matrix jobs editing the same release notes race, and the loser's
    edit is gone with nothing to show it.

  • The screenshots are a real crawl of a real site. The app shot was a
    25-page run whose numbers had drifted from what the app draws; it is now
    astro.build — 60 pages, 232 findings, 37 things to change, scored 87 — and the
    one error at the top is a link to a page that does not exist, which is the bug
    this tool was written to catch. A compare shot goes with it, docs.astro.build
    against astro.build, matched by path because the hosts differ. Settings was
    captioned "seven panes" and has had nine since Search Console shipped.

  • The site says there are three apps now, and what to do when one will not
    open.
    nurkamol.github.io/seo-audit offered a single "Download for macOS"
    button and described the app as macOS-only, which had been true for about a
    day. It now has a card per platform, the Homebrew route marked as the easier
    one, and a section for the warnings an unsigned build produces on macOS and
    Windows.

    The verify step and the quarantine command are two blocks with two copy
    buttons rather than one block with one, because a single paste that runs the
    checksum and then immediately clears the flag has verified nothing.

  • The desktop workflow can attach bundles to a tag it is given. A release
    whose upload failed can be completed by running the workflow manually, rather
    than by force-moving a tag that people may already have fetched.

Fixed

  • Three enum variants warned on every build that they were never
    constructed.
    Each is built under a #[cfg] — two on Windows, one on Linux —
    while every platform matches on all of them, because matching is not
    constructing. Silenced per variant and only where the claim is true, so a
    variant that stops being reachable on the platform that owns it still warns.

  • The first tagged release built its Windows and Linux bundles and then could
    not attach them.
    gh release upload came back with "HTTP 403: Resource not
    accessible by integration" — the workflow never asked for contents: write,
    so its token was read-only. Twenty minutes of building, installing and running
    the bundles, all of it correct, and then a refusal on the last step.

    The same commit also matched tags with a bare v*, which matches the floating
    v1 tag, so force-pushing v1 started a second full build that would have
    spent fifteen minutes failing to attach bundles to a release named v1.

    Both mistakes were already solved elsewhere in this repository, and
    mac-release.yml carries a comment describing the second one exactly. So both
    are now checked by test/workflows.test.mjs rather than written down a third
    time: a tag trigger may not be a bare v*, and a workflow that writes to a
    release must ask for permission to.

  • Refreshing a release's checksums no longer rebuilds it first. The
    checksums job needs needs: build on a tag, where the bundles have to be
    attached before anything can hash them. On a manual re-run they are already
    attached, and it was spending fifteen minutes compiling two Tauri bundles to
    hash four files it downloads from the release anyway.

    needs: cannot be conditional. A skipped job completes instantly, though, so
    the build is what became conditional — only_checksums skips it, and
    always() on the checksums job stops a skipped dependency from skipping it
    too. It still refuses to run behind a build that actually failed: hashing
    half-attached assets publishes a checksum for a release nobody should trust
    yet.

  • The checksums job had no repository to talk to. It needs a release's
    assets rather than the source, so it had no actions/checkout — and gh
    works the repository out from the git remote, so it failed with "fatal: not a
    git repository", which names a problem that was not the problem. GH_REPO
    says it directly. Checked now, like the other two: a job running gh with
    neither a checkout nor GH_REPO fails the suite.


Installing on macOS

brew tap nurkamol/seo-audit https://github.com/nurkamol/seo-audit
brew trust nurkamol/seo-audit
brew install --cask seo-audit

Homebrew verifies the download against the checksum below and clears the
quarantine flag for you, so the app opens the first time.

Downloading the zip instead? macOS may say "SEO Audit is damaged and
can't be opened."
It is not damaged. The app is ad-hoc signed rather than
notarised — notarising needs a paid Apple Developer account this project
does not have — and macOS refuses quarantined apps without a notarisation
ticket. Check what you downloaded first:

shasum -a 256 seo-audit-1.36.0-macos.zip
# 59789d5a83cbfa57021f9daa84999911c1321cefbfae1b5e4422740dfa4dd51c

Then, once it matches, clear the flag — no sudo needed:

xattr -dr com.apple.quarantine "/Applications/SEO Audit.app"

That is what right-click → Open does in the Finder, without the dialog.
Apple Silicon, macOS Tahoe or newer.

Windows and Linux

The setup.exe, .deb and .AppImage below are attached by a separate
job and may appear a few minutes after this release does.

The Windows installer is unsigned, so SmartScreen shows "Windows protected
your PC"
More info → Run anyway. Same reason as macOS: a code-signing
certificate is a paid, per-year thing this project does not have. The
.AppImage needs chmod +x before it will run.

Check any of them first. A SHA256SUMS.txt covering every file here is
attached alongside them, and the same checksums are listed at the end of
these notes:

shasum -a 256 --ignore-missing -c SHA256SUMS.txt

Checksums

Verify a download before you wave a warning away. With SHA256SUMS.txt
saved beside the file, in the same folder:

shasum -a 256 --ignore-missing -c SHA256SUMS.txt
67affeeea6eac4e1c1ed3a28a7544286db69f030e410d2591ccdd62adc270fbf  SEO.Audit_1.36.0_amd64.AppImage
94ca9978bd8063143dd7228d042348441926ef04f28bbf9e8b8caf7ff3e5a1ca  SEO.Audit_1.36.0_amd64.deb
14181cae775e06c6d89a915c6674e694fd06b0f89a1085f86e3ff45cb9f8326e  SEO.Audit_1.36.0_x64-setup.exe
59789d5a83cbfa57021f9daa84999911c1321cefbfae1b5e4422740dfa4dd51c  seo-audit-1.36.0-macos.zip

The macOS line appears twice in these notes on purpose: once above, computed
when the app was built, and once here, computed from what is actually attached.
They should agree.

1.35.0 — the app arrives on Windows and Linux

Choose a tag to compare

@github-actions github-actions released this 29 Aug 09:46

Added

  • A desktop app for Windows and Linux. desktop/ is a Tauri shell that
    starts bin/seo-audit.mjs --serve 0, reads the port off stdout and points a
    webview at it — exactly what mac/SeoAudit/Engine.swift has always done. It
    draws nothing: the report is the HTML the server already produces, so both
    platforms get the same sidebar, score ring and library, from the same folder
    on a machine that has both. The Swift app stays the macOS release.

    It carries the lessons of the window next door. Port zero, because guessing is
    how two copies fight over one. Node found by path rather than by PATH,
    because a window launched from a dock inherits almost none. And a failure that
    opens a window saying why, because an app that opens and does nothing is the
    worst version of that.

    A menu of File, Edit and Help — Edit predefined, because on Linux and Windows
    a webview without one is a text field where Ctrl-C does nothing. Every other
    item is navigation or a link. External links leave the app: a report is
    full of the audited site's own URLs, and following one used to replace the
    report with somebody else's website, in a window with no address bar to get
    out of.

    Packaged with a Node inside it: a 23 MB setup.exe, a 45 MB .deb, a 115 MB
    AppImage. Each platform builds on its own runner, then installs what it
    built, runs it, and waits for the engine to appear
    — because building a
    bundle only proves it bundles, and four separate bugs survived to that step.

  • The desktop shell knows when there is a new version, and offers the one
    thing that is safe.
    The macOS app settled the principle — the thing that
    installed it is the thing that replaces it — and it generalises unevenly,
    because the platforms differ in what a program may do without a password it
    cannot ask for. A winget install runs winget upgrade in place and offers to
    relaunch; an apt install is shown the command, because apt needs root and a
    GUI app that shells out to sudo hangs on a prompt nobody can see; an
    AppImage, an installer copy or a hand-placed one gets the release page.

    It does not download a binary and put it in place itself. That needs a
    signature to check or a checksum to compare, and an updater that overwrites an
    application on the strength of a plain HTTPS response is a supply-chain hole
    with a progress bar on it.

    Once a day, on a thread, after the window is up, with Check for Updates…
    in the menu for asking anyway. It uses GitHub's API and falls back to the Atom
    feed, because the anonymous quota is sixty an hour per address and being
    refused is ordinary — and a version learned from the feed is announced with
    "whether it is a full release could not be checked" rather than asserted,
    which is the wording the macOS app already uses.

    Native dialogs, so the shell still owns no interface of its own. And no HTTP
    client crate: the Node it already ships has fetch, and adding a TLS stack so
    a 110 MB bundle can make one request a year is the larger thing.

  • Every report can be saved, from any browser.
    /reports/<id>/export?as=… returns the file with a name that sorts, in all
    seven formats. Links rather than a native dialog: a browser downloads them and
    the desktop shell inherits the lot, so the feature landed once and every
    platform got it. The macOS app has had an Export menu since it shipped and the
    browser had none — somebody on Linux could read a report and not save one.

    The format list moved to src/exports.mjs. It was written out twice already,
    and a third copy would have been where they started disagreeing about whether
    "Structured data" is called that.

  • A tagged release carries the Windows and Linux bundles too. Pushing a
    version tag already ran three workflows; this is the fourth, and it attaches
    the setup.exe, the .deb and the AppImage beside the macOS zip. It builds
    them the same way a branch push does — including installing what it built and
    waiting for the engine — so the file on a release page is one that was proven
    to start, not one that was proven to compile.

    It never creates the release, only waits for it. The macOS job writes the
    title from the tag's annotation and the notes from the CHANGELOG section, and
    a job racing it to create an empty one would win about half the time and take
    both away. Waiting is the whole of the coordination between them.

    A winget manifest goes with it, which is what makes the Windows half of
    the updater reachable at all: winget upgrade can only move a copy winget
    installed. Submitting one opens a pull request against microsoft/winget-pkgs
    and so needs a token this repository does not have — so the job says out loud
    that it was skipped, because a release that quietly did not publish looks
    exactly like one that did.

  • Two things that were written down twice are now checked. The version
    lives in four files, and the shell refuses to start when its version and the
    engine's disagree — so a half-finished bump would have produced bundles that
    cannot run, discovered after the tag was pushed. And the winget identifier
    lives in the workflow that publishes it and the Rust that queries it; winget
    answers a name it has never heard of with silence, so that drift would have
    shipped as a Windows build that simply never finds an update. Neither had a
    symptom worth debugging. Both now fail on the machine of whoever caused them.

  • npm run test:all runs three suites, adding cargo test over desktop/.
    Same rule as the others: a toolchain that is not there, or an engine not yet
    staged beside the shell, is reported as not run and never silently skipped.

  • --serve is the desktop UI for Linux and Windows. It always was, in the
    sense that the macOS window is a thin client over exactly this server — but
    it printed a URL and offered a form with two inputs, so nobody read it that
    way and nobody could have.

    It now opens a browser when a person ran the command, and never when
    something else did. The same distinction the pipe check already made: the
    macOS window spawns this and draws its own report, and would otherwise get a
    browser it never asked for on every launch. --no-open is for the person who
    wants neither. Three platforms, nine lines, no dependency — and failing to
    open one is not a reason to refuse to serve, so the URL is printed either way.

  • Every setting the command line takes is in the form. It offered url and
    limit while the engine took a dozen parameters, so somebody at a browser
    reached a sixth of what somebody at a terminal did — and /run forwarded only
    those two, which meant a control somebody set could quietly do nothing.

    The controls are drawn from src/options.mjs, the one table that already
    knows every flag and whether a window can reach it. Adding a flag with a
    field now adds the control; a flag without one is simply not offered, which
    is a decision written down beside the flag it is about. PageSpeed and Search
    Console are drawn only where the deployment has said those credentials are
    the visitor's own to spend, so a public Worker still offers neither.

  • A Preview button beside Audit. /preview has answered clients since it
    shipped and no page ever reached it. A preview nobody can reach is a preview
    nobody uses, and the whole point of one is being reachable before the minutes
    are spent.

  • Finished runs are kept, and the browser can compare two. The macOS window
    has kept every run since 1.23.0 and --serve kept none, so somebody on Linux
    or Windows got one report and lost it the moment they audited something else.
    A seven-minute crawl should only ever happen once, and that is not a
    macOS-only claim.

    /reports lists them, /reports/<id> reopens one as the same report every
    other front end draws, and ticking two runs compares them through the same
    diff() the CLI's --baseline uses — including the cross-site path matching,
    so a rebuild can be compared with the site it replaces.

    On macOS it writes the folder the app already uses, so a crawl started in
    the window is in the browser's list a second later and the other way round:
    nothing is synchronised, exported or copied, because there is one folder.
    Elsewhere it is %APPDATA% and $XDG_DATA_HOME, which is where those systems
    keep documents an application manages.

    A deployed Worker has no library at all. worker/index.mjs must stay
    web-standard — Cloudflare has no filesystem — so the local server hands it a
    store and a Worker is handed nothing, which is also the right answer for a
    shared host: keeping strangers' crawls is a thing nobody asked for.

  • The app updates itself, rather than explaining how to. Pressing Update
    opened Terminal and pasted brew upgrade --cask seo-audit into it, which is a
    tool telling somebody how to update it. It now runs Homebrew in the window,
    streams what Homebrew says line by line, and offers Relaunch when it is
    done.

    Homebrew still does the part that matters — it verifies the download against
    the checksum the build wrote, and it keeps its own records straight, which is
    exactly why this app must not replace its own bundle behind its back. What
    changed is where it runs. No sudo: the cask installs into /Applications
    and clears the quarantine flag without one, so nothing can sit waiting for a
    password nobody can type. Terminal and Copy are still there for anyone who
    would rather watch it happen.

    Verified by downgrading a real install to 1.33.1 and pressing the button.

Fixed

  • The first desktop bundle opened, started nothing, and waited. It staged
    bin and src and not worker, which --serve imports, so it spawned its
    engine and waited fifteen seconds for an address that was never coming. The
    staging script now **starts what it staged and waits for...
Read more

A score, and three files it can write for you

Choose a tag to compare

@github-actions github-actions released this 28 Aug 22:18

Added

  • A score out of 100, and the checks that passed to earn it. Every report
    now opens with a number, a grade and a ring: 81/100 (B), followed by the
    arithmetic that produced it. A run starts at 100 and pays for what is wrong
    with it — an error-level check costs 12 points, a warning 4, spread across
    the pages it is on — so what a check costs is exactly what fixing it is
    worth, and every piece of work in Start here now carries the points it
    returns.

    This project has refused a score everywhere else and the refusal still
    stands for the thing people usually mean by one: nothing here predicts a
    ranking, estimates traffic, or grades a site against its competitors. The
    weights are not a new judgement either — every check already carries a level,
    argued over check by check when it was written, and scripts/check-levels.mjs
    reads those levels back out of the source so a check promoted from warning to
    error cannot keep its old weight. A test asserts it on the machine of whoever
    changes one.

  • What passed, and what was never checked. A report that only lists faults
    gives no way to tell a check that passed from one that never ran, and a
    missing finding reads exactly like a passing one. Both are now named: a
    Passing section listing every check the site cleared, in its own words
    ("Every page has an og:image", not og-image-missing), and a Not checked
    section saying why the rest did not apply — no page carries an image, no
    redirect map was given, PageSpeed was not asked.

    A check that could not run is left out of the score entirely rather than
    counted as passed. A site with no images has not passed the alt-text check,
    and scoring it as though it had would hand out points for doing less.

  • Errors and warnings first, notes after them. Findings used to arrive
    interleaved, and a note is "worth knowing, may be deliberate" — reading
    forty of them mixed into the faults is how a reader loses track of which is
    which. The terminal, the Markdown and the HTML now put what is wrong first
    and everything only worth knowing under its own heading, which says out loud
    that none of it costs the score anything.

  • --write-schema <file> — the JSON-LD this site could add: WebSite,
    Organization and BreadcrumbList. Every schema generator on the internet
    asks you to type the answers in; this one refuses to ask, because the moment
    it asks it is no longer describing the site — and structured data that
    describes a site inaccurately is worse than none. It is a machine-readable
    claim the page does not support, which is a manual-action category at Google.

    So the rule is narrower than the rest of this project's and it is absolute:
    every value emitted is a string this crawl read off this site. An
    organisation is named only when the site names itself in og:site_name,
    because the home page's title with its tagline still attached is a claim
    about a company's name.

    The first live run made the point better than any test: a breadcrumb built
    from <title> produced "Assets | Jekyll • Simple, blog-aware, static
    sites"
    as a step. A title is not a breadcrumb name. Now a step is named by
    the page's <h1> when it has exactly one, or by what the site's own
    navigation calls it when the links agree and the words are not "read more" —
    and by nothing else. Jekyll › Docs › Assets. On that site 142 pages were
    skipped rather than given a name invented from a slug.

    The refusal can be the good answer here, so it says which it is: a site that
    already declares everything this could write gets told exactly that, with the
    counts, rather than the same sentence a site with no evidence gets.

  • --write-llms <file> — the llms.txt this site should have had.
    Sibling to --write-sitemap, on the same premise: the crawl has already read
    every page's title, description, section and indexing directives, which is
    exactly what the file is made of. Every line is a string the site already
    serves — a page that gave us no description gets a line without one rather
    than a sentence somebody made up about it, and the H1 is the site's own name
    for itself rather than a guess.

    It refuses for the same reasons the sitemap does, and the refusals matter
    more than the file: this is a document handed to an assistant as the
    authoritative summary of a site, and one built from a third of the site is
    worse than none because it looks complete. A truncated crawl writes nothing
    and names the run that would work.

    Reachable everywhere: the flag, llms-out=1 on the Worker, the macOS Export
    menu (which says why when the engine refused), the Raycast extension, and
    a write-llms input on the Action. --write-sitemap gained the Action input
    it should always have had while the wiring was open.

  • Search Console now answers where you rank, and what for. The same
    connection, asked one more question — no second account, no scrape, no
    keyword provider, and no number that anybody here worked out.

    Every response Google has ever sent carried position, and it was being
    thrown away. It now travels with each finding, so a template on page two can
    be ordered ahead of one nobody has been shown, and the scope line says "12
    pages, 4,300 impressions, best at position 6.4"
    .

    search-console-striking names the crawled pages at positions 11 to 20 —
    page two, where the click-through rate is roughly nothing and the ranking is
    already earned — with the query each is closest on, most-shown first. It is
    the only list in this tool that is an opportunity rather than a fault, and
    moving one of them up two places is usually less work than a new page.

    A live property found the part no test would have: Google returns HTTP 200
    and zero query rows for a site under its anonymity threshold — 99
    impressions over 28 days was well under. Silence there reads exactly like
    "this site is found for nothing", so the report says which it is. The query
    call is best-effort besides: if it fails, the positions still stand.

  • Which AI crawlers a site lets in. GPTBot, OAI-SearchBot, ClaudeBot,
    Claude-SearchBot, PerplexityBot, Google-Extended, Applebot-Extended, CCBot
    and five more, asked of robots.txt through the same robotsVerdict() every
    other robots question in this project goes through — so a rule this reads and
    a rule Google reads are the same rule, longer-Allow-wins included. Nothing
    is fetched that was not already being fetched and nothing is estimated: a
    site's position on being read by the answer engines is already written down
    in a file it already serves.

    It is a note, and stays one. A publisher who does not want their work in a
    training set and says so has done the correct thing correctly, and a check
    that cries wolf gets the whole report ignored. What the finding adds is the
    distinction everybody gets wrong — an answering crawler fetches a page
    because somebody asked a question just now, so blocking it removes the site
    from that answer today; a training crawler does not, and blocking it changes
    nothing about being cited. And it says whether anybody actually decided: a
    block that arrives through User-agent: * rather than the agent's own name is
    usually a CDN or plugin default nobody has seen.

    The one thing here that is a fault gets a warning: ai-crawler-conflict, a
    site serving llms.txt — a file whose only purpose is to tell an assistant
    what to read — while robots.txt turns that assistant away. The invitation
    never gets read, and unlike the block itself, nobody chose it.

    New area, AI & answer engines, which llms-missing moves into: it is
    addressed to assistants rather than to crawlers and belongs beside the agents
    that read it.

  • /checks, served beside /options: every scored check with its weight,
    its area and what it says when it passes. "What does this thing actually
    check" is now a question with a fetchable answer rather than one that needs a
    source file read.

Changed

  • The CSV is the whole checklist, not only the faults. A points column
    carries what each failing check is taking off the score, so a spreadsheet
    sorts by what fixing something is worth rather than only by how often it
    occurs. Checks that passed arrive as rows at level pass, and ones that did
    not apply as not-checked with the reason in detail.

    Additive on purpose: points is appended after detail rather than put
    beside indexable where it reads better, so every column keeps the index it
    has had since the file shipped. Anything filtering on error, warn or
    info is untouched.

  • The portfolio table has a score column, and orders by it where every run
    has one. "Which of my twenty sites is worst" is the only question a portfolio
    exists to answer, and counting errors weighs a site-wide failure the same as a
    warning on one page of four hundred. A run that never answered prints a dash,
    never a zero.

Fixed

  • <title> and every heading arrived with their HTML entities undecoded.
    attr() has decoded since it was written, so meta descriptions were always
    fine — but a title and an <h1> are element text and went through none of
    it. So Widgets &amp; Co was the title in the terminal, in the CSV, in the
    JSON, and in the length title-long measures against what Google shows,
    where it was five characters too long. Hexadecimal numeric references were
    not handled at all, and &#x2019; is what a CMS emits for the apostrophe in
    Widget's.

    Found by generating an llms.txt for a real site and reading the first line
    of it — not by the test suite, which is the usual way round here.

  • Every export dropped the score except the two that were written first.
    It reached the terminal and the HTML and stopped there: the CSV had no column
    for it, the portfolio had no column for it...

Read more

1.33.1 — the window reads certificates again

Choose a tag to compare

@github-actions github-actions released this 24 Aug 17:45

Fixed

  • The macOS app skipped the certificate checks and blamed the hosted
    version.
    worker/index.mjs runs in two places — Cloudflare, which has no
    socket to read a certificate over, and --serve under Node, which does — and
    it switched the check off for both. The window talks to --serve, so every
    report it produced was missing tls-expiring and tls-expired, above a note
    reading "This report was produced by the hosted version". It was not.

    Two failures in one: a check quietly not run, which reads exactly like a check
    that passed, and a note naming a runtime that was not involved. --serve now
    declares that it can read certificates and gets the real check; a deployed
    Worker leaves it unset and keeps the note it has always deserved.

1.33.0 — Search Console, proven and reachable

Choose a tag to compare

@github-actions github-actions released this 24 Aug 17:27

Added

  • The app tells you an update exists, and offers to fetch it. It knew
    before — checkIfDue() has had a one-a-day guard for versions — but it was
    only called from the main view's .task, which runs once when the window
    appears. An app left open for a week checked once, in that week's first
    minute, and a release cut the next morning went unmentioned until somebody
    quit and came back. And when it did know, the only place that said so was a
    Settings pane somebody had to think to open.

    Now an hourly timer and didBecomeActive both funnel through the same
    day-old guard — the timer for an app left running, becoming active for the
    laptop that was shut overnight, since a timer neither fires while asleep nor
    keeps its schedule afterwards.

    A banner sits above the report rather than in front of it: a new version is
    worth mentioning and never worth interrupting a crawl for. Dismissing it
    dismisses that version for good, because a bar that returns every launch is a
    bar people learn to ignore.

    Downloading shows a real fraction where GitHub sent a length and an
    indeterminate spinner where it did not — a bar that sits at zero and jumps to
    full is worse than one that admits it cannot say. It unpacks with ditto,
    which is what wrote the archive and what keeps the bundle's signature intact.

    It stops at the drag, deliberately. Replacing a running bundle safely
    needs a helper process that outlives the app it is overwriting, which is
    Sparkle's whole job; and a Homebrew install has one correct answer that is not
    this one, since overwriting the bundle behind brew's back leaves its records
    describing a version that is no longer there. So a cask install gets the
    brew command run in Terminal where it can be watched, and everything else
    gets the file revealed in Finder.

  • A Search Console pane in the macOS app, and the whole Help row is now the
    click target rather than its chevron. DisclosureGroup only hit-tests its own
    triangle, which left a full-width row that looked clickable everywhere and
    answered in one corner — that reads as the app being broken, not as a small
    control.

    The pane takes the property and offers a sign-in, which runs the engine's own
    --search-console-login through the bundled Node. The token never passes
    through the app and is never displayed: a token on screen is a token in a
    screenshot. What comes back is the list of properties the account can read,
    each with a button to use it, because a token that can read nothing looks
    exactly like one that works until a run says the property was not found.

    The hosted Worker will not honour ?search-console= unless the runtime
    sets ALLOW_SEARCH_CONSOLE, and --serve sets it only because it binds to
    the loopback address. The credentials belong to whoever started the server, so
    a deployed Worker accepting a property name would hand a stranger somebody
    else's traffic data. Gated, shape-checked, and tested.

  • A Help pane in the macOS app. Written as the questions the app actually
    raises — why a crawl takes minutes, why the page count differs from the
    sitemap's, where the score is, why performance is blank until it is switched
    on, what leaves the machine — rather than a tour of the controls. A control
    that needs explaining is better renamed; a decision is what needs saying,
    and this app makes several that surprise people.

    Folded away rather than expanded: a wall of prose in a settings window is a
    wall nobody reads, and a list of questions is scannable.

  • --search-console-login. The three Search Console credentials were
    documented for a year and there was never a way to obtain the third, which is
    the actual reason --search-console had never run against the live API: not
    the code, the paperwork in front of it.

    Loopback OAuth, which is what Google calls the installed-app flow — a desktop
    client may redirect to any port on 127.0.0.1 without registering it, so this
    listens on an ephemeral one and the browser does the signing in. Read-only
    scope. The refresh token is written to ~/.config/seo-audit/.env at mode
    600 and never printed, because a token echoed to a terminal is a token in a
    scrollback buffer. It rewrites that one line and leaves the PageSpeed key
    alone, which is tested, because clobbering somebody's key to save a token
    would be a poor trade.

    Then it lists the properties the account can read. A token that can read
    nothing looks exactly like one that works, right up until an audit says the
    property was not found.

    Not a GitHub Action input, deliberately: it opens a browser, and a flag CI
    can accept and never satisfy is worse than no flag. src/options.mjs records
    the same answer for the macOS window.

    The parts that can be wrong quietly are separate exported functions with
    tests — the authorisation URL, the token exchange, the file rewrite — and the
    loopback flow itself is exercised end to end with only Google faked,
    including that a reply carrying the wrong state is refused and writes
    nothing.

Fixed

  • Search Console could never read its credentials from the dotfile. It had
    its own copy of the loader that psi.mjs uses, and its copy built the pattern
    with new RegExp and a template literal, where \\s survives as an escaped
    backslash rather than as whitespace. The regex compiled to
    /^\\s*GSC_CLIENT_ID\\s*=.../m — a literal backslash followed by s — so it
    could not match a line of a real .env. It never threw. Only environment
    variables ever worked.

    Nothing caught it because the only tests for that path injected credentials
    and used a fake API, which is exactly what "never run against the live API"
    hides. There is now one loader in config.mjs that both callers use, and it
    is tested against a dotfile with the whitespace a hand-edited file actually
    has — including a name that is a prefix of another, which the broken pattern
    would also have got wrong.

1.32.0 — five things a real site found

Choose a tag to compare

@github-actions github-actions released this 24 Aug 16:14

Added

  • twitter-image-broken — a declared twitter:image that does not load.
    The absence of a Twitter card stays unreported and always will: X falls back
    to Open Graph correctly, so reporting it would invent a defect. This is the
    opposite case, and the roadmap's rejection of the first did not cover it —
    nothing falls back to anything when the tag is present and 404s, and the one
    platform handed its own tag previews blank.

    Only when it differs from og:image, so one picture never produces two
    findings, and only on 404/410/no-answer, because a 403 is hotlink protection
    working. All four silent cases are tested: same picture, no tag at all, a
    403, and a redirect to the real file.

  • body-not-html — a URL the server calls HTML that is not HTML. The body
    is already read, so it costs no request, and it replaces the checks it
    silences rather than joining them. A real site serves an XML document at
    /locations.kml with Content-Type: text/html; the crawl believed the header
    and reported thirteen things — no title, no h1, no viewport, no charset, thin
    content, three Open Graph tags — every one of them true about a document that
    was never a page, and not one of them the thing to fix. Now it is one finding
    saying what is actually wrong, and the reason it matters: Google indexes
    whatever comes back under text/html.

    It answers on positive evidence only. XHTML opens with an XML prologue and is
    HTML; a fragment with no <html> wrapper is HTML; a byte-order mark before
    the doctype is HTML; something merely starting with a brace is not JSON. All
    four are tested, because guessing wrong here would silence every check on a
    real page — a far worse failure than the noise it removes.

Fixed

  • A dead site with an expired certificate is told what is actually wrong.
    expired.badssl.com came back as "The site did not answer a single request …
    The TLS connection succeeds but no response arrives … Cloudflare Bot Fight
    Mode does exactly this."
    Both halves were wrong: the TLS connection does not
    succeed, and the cause is a certificate that ran out in 2015. The site checks
    that would have named it never run, because the crawl gives up first — and a
    browser refuses an expired certificate exactly as fetch does, so from the
    crawl's side "nothing answered" and "the certificate lapsed" are the same
    silence.

    The certificate is now read before bot protection is blamed, over the same
    non-validating socket the check already used, and the report says
    "The TLS certificate expired 4151 day(s) ago … which is why nothing here
    could be fetched."
    Found by asking whether the Raycast extension really gets
    the certificate checks: it does, and this hole was underneath the question.

    Where the certificate cannot be read at all — the hosted Worker has no socket
    — the old wording stands unchanged, minus the sentence claiming the
    certificate is fine. A runtime that cannot run a check says so rather than
    implying a result.

  • One row named one Open Graph tag and counted three. A run against a real
    site printed Missing og:description ×6 over four pages — three of which were
    missing only the description, one missing all three tags — and listed that one
    page three times. Every finding under it was true. The sentence above them was
    not, and a summary that misreports is the thing grouping exists to prevent.

    The cause: og:title, og:description and og:image were pushed under one
    id, and a group takes its title from the finding it saw first. They are now
    og-title-missing, og-description-missing and og-image-missing — three
    ids, because they are three different repairs. A missing og:image is a
    picture somebody has to make; a missing og:title is one line of template.
    Splitting them also makes each separately ignorable, so a site that ships no
    og:image on purpose can silence that without silencing the other two.

    --ignore og-missing keeps working and still silences all three. Splitting a
    check is our decision, not the decision of whoever wrote that config, and an
    upgrade that promises to be compatible should not start failing their build.

    Not generalised, and the reason is worth writing down: four other ids also
    carry more than one title — img-alt-long, img-dimensions, heading-skip,
    anchor-ambiguous — and all four are correct. They are the same problem with
    the page's own numbers in the title. A test asserting one id, one title would
    have flagged the four right ones and this was the only wrong one.

  • A Raycast preference could do nothing and nobody would know. The
    extension's hand-written Preferences type listed three of the thirteen
    preferences crawlOptions() actually reads; the other ten were invisible to
    every caller. It is now ExtensionPreferences, which the Raycast build
    generates from the manifest, so it cannot disagree with what the manifest
    says — and the two components that read preferences had been calling
    getPreferenceValues() untyped, which is what the change surfaced
    immediately. Arguments.Audit replaces a second hand-copied shape.

    Types only cover half of it: present.mjs is plain ESM so node --test can
    run it, so a test now checks in both directions that every preference the
    manifest declares is read and that nothing reads a preference the manifest
    does not declare. A setting that quietly does nothing is the same failure
    src/options.mjs exists to prevent for the macOS window.

    Found by Greptile on the Store submission, which is worth recording: it
    called the type duplication a maintainability nit, and the count made it a
    defect.

  • A release no longer starts by failing. The macOS job builds on a tag and
    attaches the app to that tag's release — a release the documented procedure
    created afterwards, by hand. So every release began the same way: a red
    release not found, a gh release create, a re-run. A failure that is part
    of the normal procedure is a failure nobody reads, and the next one, which is
    real, reads exactly the same.

    It now creates the release when it is missing, titled from the tag's own
    annotation with that version's CHANGELOG section as the notes — both already
    written by the time a tag is pushed, so neither becomes a second copy to keep
    in step. A tag with no CHANGELOG section gets a warning and generated notes
    rather than a lost build, because by then the app is already sitting there
    signed.

    The section is found with index() rather than a regex, which is the bug that
    was there to be written: ## [1.31.0] interpolated into a pattern makes
    [1.31.0] a character class matching the digits 0, 1 and 3. It would
    not have errored. It would have published the wrong section, or none.

Changed

  • npx @nurkamol/seo-audit is the install the site and README lead with.
    The repository form still works and is still documented, pinned — but it
    clones 16 MB of app sources, screenshots and tests to reach a 115 kB crawler,
    measured rather than guessed. npm i -g is written down too, for anyone who
    would rather type seo-audit.