diff --git a/Dockerfile b/Dockerfile index c360b4e2abd3..26217363aca5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -52,6 +52,9 @@ COPY tsconfig.json ./tsconfig.json RUN npx tsc +# We need to copy data in order to do the build +COPY --chown=node:node data ./data + RUN npm run build # -------------------------------------------------------------------------------- @@ -85,7 +88,6 @@ ENV AIRGAP true # Copy only what's needed to run the server COPY --chown=node:node assets ./assets COPY --chown=node:node content ./content -COPY --chown=node:node data ./data COPY --chown=node:node includes ./includes COPY --chown=node:node layouts ./layouts COPY --chown=node:node lib ./lib diff --git a/content/actions/guides/installing-an-apple-certificate-on-macos-runners-for-xcode-development.md b/content/actions/guides/installing-an-apple-certificate-on-macos-runners-for-xcode-development.md index 64ba326ff72a..988e187c5bc7 100644 --- a/content/actions/guides/installing-an-apple-certificate-on-macos-runners-for-xcode-development.md +++ b/content/actions/guides/installing-an-apple-certificate-on-macos-runners-for-xcode-development.md @@ -100,12 +100,12 @@ jobs: echo -n "$BUILD_PROVISION_PROFILE_BASE64" | base64 --decode --output $PP_PATH # create temporary keychain - security create-keychain -p $KEYCHAIN_PASSWORD $KEYCHAIN_PATH + security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH security set-keychain-settings -lut 21600 $KEYCHAIN_PATH - security unlock-keychain -p $KEYCHAIN_PASSWORD $KEYCHAIN_PATH + security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH # import certificate to keychain - security import $CERTIFICATE_PATH -P $P12_PASSWORD -A -t cert -f pkcs12 -k $KEYCHAIN_PATH + security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH security list-keychain -d user -s $KEYCHAIN_PATH # apply provisioning profile diff --git a/content/actions/managing-workflow-runs/using-the-visualization-graph.md b/content/actions/managing-workflow-runs/using-the-visualization-graph.md index 2fbeb2a9ae2a..df1a4ef56653 100644 --- a/content/actions/managing-workflow-runs/using-the-visualization-graph.md +++ b/content/actions/managing-workflow-runs/using-the-visualization-graph.md @@ -9,7 +9,6 @@ versions: --- {% data reusables.actions.enterprise-beta %} -{% data reusables.actions.visualization-beta %} {% data reusables.actions.enterprise-github-hosted-runners %} {% data reusables.actions.ae-beta %} diff --git a/data/features/README.md b/data/features/README.md new file mode 100644 index 000000000000..c2fca9380d58 --- /dev/null +++ b/data/features/README.md @@ -0,0 +1,52 @@ +## Feature-based versioning + +Feature-based versioning allows us to define and control the versions of an arbitrarily named "feature" in one place. + +**Note**: Do not delete `data/features/placeholder.yml` because it is used by tests. + +## How it works + +Add a new YAML file with the feature name you want to use in this directory. For a feature named `meow`, that would be `data/features/meow.yml`. + +Add a `versions` block to the YML file with the short names of the versions the feature is available in. For example: + +```yaml +versions: + fpt: '*' + ghes: '>3.1' + ghae: '*' +``` + +The format and allowed values are the same as the [frontmatter versions property](/content#versions). + +### Liquid conditionals + +Now you can use `{% if meow %} ... {% endif %}` in content files! Note this is the `if` tag, not the new `ifversion` tag. + +### Frontmatter + +You can also use the feature in frontmatter in content files: + +```yaml +versions: + fpt: '*' + ghes: '>3.1' + feature: 'meow' +``` + +If you want a content file to apply to more than one feature, you can do this: + +```yaml +versions: + fpt: '*' + ghes: '>3.1' + feature: ['meow', 'blorp'] +``` + +## Schema enforcement + +The schema for validating the feature versioning lives in [`tests/helpers/schemas/feature-versions.js`](tests/helpers/schemas/feature-versions.js) and is exercised by [`tests/content/lint-files.js`](tests/content/lint-files.js). + +## Script to remove feature tags + +TBD! diff --git a/data/features/placeholder.yml b/data/features/placeholder.yml new file mode 100644 index 000000000000..a133861e3570 --- /dev/null +++ b/data/features/placeholder.yml @@ -0,0 +1,4 @@ +# Do not delete! Used by tests. +versions: + ghes: '>3.0' + ghae: '*' diff --git a/data/reusables/actions/visualization-beta.md b/data/reusables/actions/visualization-beta.md deleted file mode 100644 index da3ac2b0d2b4..000000000000 --- a/data/reusables/actions/visualization-beta.md +++ /dev/null @@ -1,7 +0,0 @@ -{% if currentVersion == "free-pro-team@latest" or currentVersion ver_gt "enterprise-server@3.0" or currentVersion == "github-ae@latest" %} -{% note %} - -**Note:** The workflow visualization graph for {% data variables.product.prodname_actions %} is currently in beta and subject to change. - -{% endnote %} -{% endif %} diff --git a/data/reusables/command_line/providing-token-as-password.md b/data/reusables/command_line/providing-token-as-password.md index 54cb54e48e65..a938fcfd9ca3 100644 --- a/data/reusables/command_line/providing-token-as-password.md +++ b/data/reusables/command_line/providing-token-as-password.md @@ -4,6 +4,6 @@ For example, on the command line you would enter the following: ```shell $ git clone https://{% data variables.command_line.codeblock %}/username/repo.git -Username: your_username +Username: your_username Password: your_token ``` diff --git a/lib/feature-flags.js b/lib/feature-flags.js index 970599f0733b..f125b40b8cfe 100644 --- a/lib/feature-flags.js +++ b/lib/feature-flags.js @@ -1,4 +1,5 @@ -const featureFlags = require('../feature-flags') +const readJsonFile = require('./read-json-file') +const featureFlags = readJsonFile('./feature-flags.json') // add feature flags as environment variables Object.entries(featureFlags).forEach(([feature, value]) => { diff --git a/lib/frontmatter.js b/lib/frontmatter.js index 3fb6fafe8545..b93f40608e8e 100644 --- a/lib/frontmatter.js +++ b/lib/frontmatter.js @@ -1,3 +1,5 @@ +const fs = require('fs') +const path = require('path') const parse = require('./read-frontmatter') const semver = require('semver') const layouts = require('./layouts') @@ -9,8 +11,10 @@ const semverRange = { conform: semverValidRange, message: 'Must be a valid SemVer range' } -const versionIds = Object.keys(require('./all-versions')) +const versionObjs = Object.values(require('./all-versions')) const guideTypes = ['overview', 'quick_start', 'tutorial', 'how_to', 'reference'] +const featureVersions = fs.readdirSync(path.posix.join(process.cwd(), 'data/features')) + .map(file => path.basename(file, '.yml')) const schema = { properties: { @@ -197,15 +201,31 @@ const schema = { } } +const featureVersionsProp = { + feature: { + type: ['string', 'array'], + enum: featureVersions, + items: { + type: 'string' + }, + message: 'must be the name (or names) of a feature that matches "filename" in data/features/_filename_.yml' + } +} + schema.properties.versions = { type: ['object', 'string'], // allow a '*' string to indicate all versions required: true, - properties: versionIds.reduce((acc, versionId) => { - acc[versionId] = semverRange + properties: versionObjs.reduce((acc, versionObj) => { + acc[versionObj.plan] = semverRange + acc[versionObj.shortName] = semverRange return acc - }, {}) + }, featureVersionsProp) } +// Support 'github-ae': next +schema.properties.versions.properties['github-ae'] = 'next' +schema.properties.versions.properties.ghae = 'next' + function frontmatter (markdown, opts = {}) { const defaults = { schema, diff --git a/lib/get-applicable-versions.js b/lib/get-applicable-versions.js index 5ac635e5019a..d7adb2408075 100644 --- a/lib/get-applicable-versions.js +++ b/lib/get-applicable-versions.js @@ -1,6 +1,17 @@ +const path = require('path') +const { reduce, sortBy } = require('lodash') const allVersions = require('./all-versions') const versionSatisfiesRange = require('./version-satisfies-range') const checkIfNextVersionOnly = require('./check-if-next-version-only') +const dataDirectory = require('./data-directory') +const encodeBracketedParentheses = require('./encode-bracketed-parentheses') +const featuresDir = path.posix.join(__dirname, '../data/features') + +const featureData = dataDirectory(featuresDir, { + preprocess: dataString => + encodeBracketedParentheses(dataString.trimEnd()), + ignorePatterns: [/README\.md$/] +}) // return an array of versions that an article's product versions encompasses function getApplicableVersions (frontmatterVersions, filepath) { @@ -13,17 +24,63 @@ function getApplicableVersions (frontmatterVersions, filepath) { return Object.keys(allVersions) } - // get an array like: [ 'free-pro-team@latest', 'enterprise-server@2.21', 'enterprise-cloud@latest' ] - const applicableVersions = [] + // Check for frontmatter that includes a feature name, like: + // fpt: '*' + // feature: 'foo' + // or multiple feature names, like: + // fpt: '*' + // feature: ['foo', 'bar'] + // and add the versions affiliated with the feature (e.g., foo) to the frontmatter versions object: + // fpt: '*' + // ghes: '>=2.23' + // ghae: '*' + // where the feature is bringing the ghes and ghae versions into the mix. + const featureVersions = reduce(frontmatterVersions, (result, value, key) => { + if (key === 'feature') { + if (typeof value === 'string') { + Object.assign(result, { ...featureData[value].versions }) + } else if (Array.isArray(value)) { + value.forEach(str => { + Object.assign(result, { ...featureData[str].versions }) + }) + } + delete result[key] + } + return result + }, {}) + + // We will be evaluating feature versions separately, so we can remove this. + delete frontmatterVersions.feature + + // Get available versions for frontmatter and for feature versions. + const foundFeatureVersions = evaluateVersions(featureVersions) + const foundFrontmatterVersions = evaluateVersions(frontmatterVersions) + + // Combine them! + const applicableVersions = [...new Set(foundFrontmatterVersions.versions.concat(foundFeatureVersions.versions))] + + if (!applicableVersions.length && !foundFrontmatterVersions.isNextVersionOnly && !foundFeatureVersions.isNextVersionOnly) { + throw new Error(`No applicable versions found for ${filepath}. Please double-check the page's \`versions\` frontmatter.`) + } + // Sort them by the order in lib/all-versions. + const sortedVersions = sortBy(applicableVersions, (v) => { return Object.keys(allVersions).indexOf(v) }) + + return sortedVersions +} + +function evaluateVersions (versionsObj) { let isNextVersionOnly = false - // where frontmatter is something like: + // get an array like: [ 'free-pro-team@latest', 'enterprise-server@2.21', 'enterprise-cloud@latest' ] + const versions = [] + + // where versions obj is something like: // fpt: '*' // ghes: '>=2.19' // ghae: '*' // ^ where each key corresponds to a plan's short name (defined in lib/all-versions.js) - Object.entries(frontmatterVersions) + Object.entries(versionsObj) .forEach(([plan, planValue]) => { // Special handling for frontmatter that evalues to the next GHES release number or a hardcoded `next`. isNextVersionOnly = checkIfNextVersionOnly(planValue) @@ -37,16 +94,12 @@ function getApplicableVersions (frontmatterVersions, filepath) { const versionToCompare = relevantVersion.hasNumberedReleases ? relevantVersion.currentRelease : '1.0' if (versionSatisfiesRange(versionToCompare, planValue)) { - applicableVersions.push(relevantVersion.version) + versions.push(relevantVersion.version) } }) }) - if (!applicableVersions.length && !isNextVersionOnly) { - throw new Error(`No applicable versions found for ${filepath}. Please double-check the page's \`versions\` frontmatter.`) - } - - return applicableVersions + return { versions, isNextVersionOnly } } module.exports = getApplicableVersions diff --git a/lib/read-json-file.js b/lib/read-json-file.js new file mode 100644 index 000000000000..353bdd078bb4 --- /dev/null +++ b/lib/read-json-file.js @@ -0,0 +1,14 @@ +const fs = require('fs') +const path = require('path') + +module.exports = function readJsonFile (xpath) { + return JSON.parse( + fs.readFileSync( + path.join( + process.cwd(), + xpath + ), + 'utf8' + ) + ) +} diff --git a/lib/redirects/precompile.js b/lib/redirects/precompile.js index 9112bb32f427..cd3b4dfa7587 100755 --- a/lib/redirects/precompile.js +++ b/lib/redirects/precompile.js @@ -1,4 +1,5 @@ -const developerRedirects = require('../redirects/static/developer') +const readJsonFile = require('../read-json-file') +const developerRedirects = readJsonFile('./lib/redirects/static/developer.json') const { latest } = require('../../lib/enterprise-server-releases') const latestDevRedirects = {} diff --git a/lib/render-content/plugins/rewrite-local-links.js b/lib/render-content/plugins/rewrite-local-links.js index 5e121c333f16..a357a312edc5 100644 --- a/lib/render-content/plugins/rewrite-local-links.js +++ b/lib/render-content/plugins/rewrite-local-links.js @@ -1,15 +1,17 @@ const path = require('path') const visit = require('unist-util-visit') -const externalRedirects = Object.keys(require('../../redirects/external-sites')) const { getPathWithoutLanguage, getVersionStringFromPath } = require('../../path-utils') const { getNewVersionedPath } = require('../../old-versions-utils') const patterns = require('../../patterns') const { deprecated, latest } = require('../../enterprise-server-releases') const nonEnterpriseDefaultVersion = require('../../non-enterprise-default-version') const allVersions = require('../../all-versions') +const removeFPTFromPath = require('../../remove-fpt-from-path') const supportedVersions = Object.keys(allVersions) const supportedPlans = Object.values(allVersions).map(v => v.plan) -const removeFPTFromPath = require('../../remove-fpt-from-path') +const readJsonFile = require('../../read-json-file') +const externalRedirects = Object.keys(readJsonFile('./lib/redirects/external-sites.json')) + // Matches any tags with an href that starts with `/` const matcher = node => ( diff --git a/lib/rewrite-local-links.js b/lib/rewrite-local-links.js index 947c6846841e..d30101f28cd6 100644 --- a/lib/rewrite-local-links.js +++ b/lib/rewrite-local-links.js @@ -1,6 +1,5 @@ const assert = require('assert') const path = require('path') -const externalRedirects = Object.keys(require('./redirects/external-sites')) const { getPathWithoutLanguage, getVersionStringFromPath } = require('./path-utils') const { getNewVersionedPath } = require('./old-versions-utils') const patterns = require('./patterns') @@ -10,6 +9,8 @@ const allVersions = require('./all-versions') const supportedVersions = Object.keys(allVersions) const supportedPlans = Object.values(allVersions).map(v => v.plan) const removeFPTFromPath = require('./remove-fpt-from-path') +const readJsonFile = require('./read-json-file') +const externalRedirects = readJsonFile('./lib/redirects/external-sites.json') // Content authors write links like `/some/article/path`, but they need to be // rewritten on the fly to match the current language and page version diff --git a/lib/search/algolia-search.js b/lib/search/algolia-search.js index 4f415a9c5c09..4b915a52b9fa 100644 --- a/lib/search/algolia-search.js +++ b/lib/search/algolia-search.js @@ -1,6 +1,6 @@ const algoliasearch = require('algoliasearch') const { get } = require('lodash') -const { namePrefix } = require('./config') +const { namePrefix } = require('./config.js') // https://www.algolia.com/apps/ZI5KPY1HBE/dashboard // This API key is public. There's also a private API key for writing to the Algolia API diff --git a/lib/search/lunr-search.js b/lib/search/lunr-search.js index 5d01d60faab8..8fddc5ff35d2 100644 --- a/lib/search/lunr-search.js +++ b/lib/search/lunr-search.js @@ -8,7 +8,7 @@ require('lunr-languages/lunr.pt')(lunr) require('lunr-languages/lunr.de')(lunr) const { get } = require('lodash') const readFileAsync = require('../readfile-async') -const { namePrefix } = require('./config') +const { namePrefix } = require('./config.js') const { decompress } = require('./compress') const LUNR_DIR = './indexes' diff --git a/middleware/archived-enterprise-versions.js b/middleware/archived-enterprise-versions.js index 3a5cd96f7fcc..c599a49db1df 100644 --- a/middleware/archived-enterprise-versions.js +++ b/middleware/archived-enterprise-versions.js @@ -5,8 +5,9 @@ const patterns = require('../lib/patterns') const versionSatisfiesRange = require('../lib/version-satisfies-range') const isArchivedVersion = require('../lib/is-archived-version') const got = require('got') -const archvivedRedirects = require('../lib/redirects/static/archived-redirects-from-213-to-217') -const archivedFrontmatterFallbacks = require('../lib/redirects/static/archived-frontmatter-fallbacks') +const readJsonFile = require('../lib/read-json-file') +const archvivedRedirects = readJsonFile('./lib/redirects/static/archived-redirects-from-213-to-217.json') +const archivedFrontmatterFallbacks = readJsonFile('./lib/redirects/static/archived-frontmatter-fallbacks.json') // This module handles requests for deprecated GitHub Enterprise versions // by routing them to static content in help-docs-archived-enterprise-versions diff --git a/middleware/context.js b/middleware/context.js index 42cfcfe168ed..9eedd09a98d5 100644 --- a/middleware/context.js +++ b/middleware/context.js @@ -11,7 +11,8 @@ const { } = require('../lib/path-utils') const productNames = require('../lib/product-names') const warmServer = require('../lib/warm-server') -const featureFlags = Object.keys(require('../feature-flags')) +const readJsonFile = require('../lib/read-json-file') +const featureFlags = Object.keys(readJsonFile('./feature-flags.json')) const builtAssets = require('../lib/built-asset-urls') const searchVersions = require('../lib/search/versions') const nonEnterpriseDefaultVersion = require('../lib/non-enterprise-default-version') diff --git a/middleware/contextualizers/features.js b/middleware/contextualizers/features.js new file mode 100644 index 000000000000..89e5dfb84cc8 --- /dev/null +++ b/middleware/contextualizers/features.js @@ -0,0 +1,18 @@ +const getApplicableVersions = require('../../lib/get-applicable-versions') + +module.exports = async function features (req, res, next) { + if (!req.context.page) return next() + + // Determine whether the currentVersion belongs to the list of versions the feature is available in. + Object.keys(req.context.site.data.features).forEach(featureName => { + const { versions } = req.context.site.data.features[featureName] + const applicableVersions = getApplicableVersions(versions, req.path) + + // Adding the resulting boolean to the context object gives us the ability to use + // `{% if featureName ... %}` conditionals in content files. + const isFeatureAvailableInCurrentVersion = applicableVersions.includes(req.context.currentVersion) + req.context[featureName] = isFeatureAvailableInCurrentVersion + }) + + return next() +} diff --git a/middleware/contextualizers/graphql.js b/middleware/contextualizers/graphql.js index f07a1506ed9e..037f4c0a3b00 100644 --- a/middleware/contextualizers/graphql.js +++ b/middleware/contextualizers/graphql.js @@ -1,10 +1,11 @@ const fs = require('fs') const path = require('path') -const previews = require('../../lib/graphql/static/previews') -const upcomingChanges = require('../../lib/graphql/static/upcoming-changes') -const changelog = require('../../lib/graphql/static/changelog') -const prerenderedObjects = require('../../lib/graphql/static/prerendered-objects') -const prerenderedInputObjects = require('../../lib/graphql/static/prerendered-input-objects') +const readJsonFile = require('../../lib/read-json-file') +const previews = readJsonFile('./lib/graphql/static/previews.json') +const upcomingChanges = readJsonFile('./lib/graphql/static/upcoming-changes.json') +const changelog = readJsonFile('./lib/graphql/static/changelog.json') +const prerenderedObjects = readJsonFile('./lib/graphql/static/prerendered-objects.json') +const prerenderedInputObjects = readJsonFile('./lib/graphql/static/prerendered-input-objects.json') const allVersions = require('../../lib/all-versions') const explorerUrl = process.env.NODE_ENV === 'production' diff --git a/middleware/index.js b/middleware/index.js index 32d875949b65..e6198c1826fa 100644 --- a/middleware/index.js +++ b/middleware/index.js @@ -51,6 +51,7 @@ const currentProductTree = require('./contextualizers/current-product-tree') const genericToc = require('./contextualizers/generic-toc') const breadcrumbs = require('./contextualizers/breadcrumbs') const earlyAccessBreadcrumbs = require('./contextualizers/early-access-breadcrumbs') +const features = require('./contextualizers/features') const productExamples = require('./contextualizers/product-examples') const devToc = require('./dev-toc') const featuredLinks = require('./featured-links') @@ -180,6 +181,7 @@ module.exports = function (app) { app.use(asyncMiddleware(instrument(genericToc, './contextualizers/generic-toc'))) app.use(asyncMiddleware(instrument(breadcrumbs, './contextualizers/breadcrumbs'))) app.use(asyncMiddleware(instrument(earlyAccessBreadcrumbs, './contextualizers/early-access-breadcrumbs'))) + app.use(asyncMiddleware(instrument(features, './contextualizers/features'))) app.use(asyncMiddleware(instrument(productExamples, './contextualizers/product-examples'))) app.use(asyncMiddleware(instrument(devToc, './dev-toc'))) diff --git a/middleware/redirects/external.js b/middleware/redirects/external.js index b6172696e3a1..71c8344ed23c 100644 --- a/middleware/redirects/external.js +++ b/middleware/redirects/external.js @@ -1,4 +1,5 @@ -const externalSites = require('../../lib/redirects/external-sites') +const readJsonFile = require('../../lib/read-json-file') +const externalSites = readJsonFile('./lib/redirects/external-sites.json') // blanket redirects to external websites module.exports = function externalRedirects (req, res, next) { diff --git a/script/graphql/update-files.js b/script/graphql/update-files.js index 50da5fbbf783..408b45893b7e 100755 --- a/script/graphql/update-files.js +++ b/script/graphql/update-files.js @@ -8,7 +8,7 @@ const { execSync } = require('child_process') const graphqlDataDir = path.join(process.cwd(), 'data/graphql') const graphqlStaticDir = path.join(process.cwd(), 'lib/graphql/static') const { getContents, listMatchingRefs } = require('../helpers/git-utils') -const dataFilenames = require('./utils/data-filenames') +const dataFilenames = JSON.parse(fs.readFileSync('./utils/data-filenames.json')) const allVersions = require('../../lib/all-versions') const processPreviews = require('./utils/process-previews') const processUpcomingChanges = require('./utils/process-upcoming-changes') diff --git a/script/graphql/utils/process-schemas.js b/script/graphql/utils/process-schemas.js index 16fde19ff2c0..ad55211c6acf 100755 --- a/script/graphql/utils/process-schemas.js +++ b/script/graphql/utils/process-schemas.js @@ -1,8 +1,9 @@ const { sortBy } = require('lodash') const { parse, buildASTSchema } = require('graphql') const helpers = require('./schema-helpers') +const fs = require('fs') -const externalScalars = require('../../../lib/graphql/non-schema-scalars') +const externalScalars = JSON.parse(fs.readFileSync('../../../lib/graphql/non-schema-scalars.json')) .map(scalar => { scalar.id = helpers.getId(scalar.name) scalar.href = helpers.getFullLink('scalars', scalar.id) diff --git a/script/graphql/utils/schema-helpers.js b/script/graphql/utils/schema-helpers.js index 80ed730b7d4c..21958e36014f 100644 --- a/script/graphql/utils/schema-helpers.js +++ b/script/graphql/utils/schema-helpers.js @@ -1,5 +1,6 @@ const renderContent = require('../../../lib/render-content') -const graphqlTypes = require('../../../lib/graphql/types') +const fs = require('fs') +const graphqlTypes = JSON.parse(fs.readFileSync('../../../lib/graphql/types.json')) const { isScalarType, isObjectType, diff --git a/tests/browser/browser.js b/tests/browser/browser.js index 88138c7ed564..6c1b12e8090d 100644 --- a/tests/browser/browser.js +++ b/tests/browser/browser.js @@ -4,7 +4,7 @@ const path = require('path') const sleep = require('await-sleep') const { latest } = require('../../lib/enterprise-server-releases') const languages = require('../../lib/languages') -const featureFlags = JSON.parse(fs.readFileSync(path.join(process.cwd(), '/feature-flags.json'))) +const featureFlags = JSON.parse(fs.readFileSync(path.join(process.cwd(), './feature-flags.json'))) describe('homepage', () => { jest.setTimeout(60 * 1000) diff --git a/tests/content/graphql.js b/tests/content/graphql.js index 67d2735f6b8a..3f0a3216a848 100644 --- a/tests/content/graphql.js +++ b/tests/content/graphql.js @@ -1,13 +1,14 @@ const fs = require('fs') const path = require('path') -const previewsJson = require('../../lib/graphql/static/previews') -const upcomingChangesJson = require('../../lib/graphql/static/upcoming-changes') -const prerenderedObjectsJson = require('../../lib/graphql/static/prerendered-objects') +const readJsonFile = require('../../lib/read-json-file') +const previewsJson = readJsonFile('./lib/graphql/static/previews.json') +const upcomingChangesJson = readJsonFile('./lib/graphql/static/upcoming-changes.json') +const prerenderedObjectsJson = readJsonFile('./lib/graphql/static/prerendered-objects.json') const { schemaValidator, previewsValidator, upcomingChangesValidator } = require('../../lib/graphql/validator') const revalidator = require('revalidator') const allVersions = Object.values(require('../../lib/all-versions')) const graphqlVersions = allVersions.map(v => v.miscVersionName) -const graphqlTypes = require('../../lib/graphql/types').map(t => t.kind) +const graphqlTypes = readJsonFile('./lib/graphql/types.json').map(t => t.kind) describe('graphql json files', () => { jest.setTimeout(3 * 60 * 1000) diff --git a/tests/fixtures/feature-versions-frontmatter.md b/tests/fixtures/feature-versions-frontmatter.md new file mode 100644 index 000000000000..3b34a276c3e7 --- /dev/null +++ b/tests/fixtures/feature-versions-frontmatter.md @@ -0,0 +1,7 @@ +--- +title: Some article only versioned for FPT +versions: + fpt: '*' + ghes: '>2.21' + feature: 'placeholder' +--- diff --git a/tests/graphql/build-changelog-test.js b/tests/graphql/build-changelog-test.js index e27f2f86a839..7c04d2edf5ec 100644 --- a/tests/graphql/build-changelog-test.js +++ b/tests/graphql/build-changelog-test.js @@ -3,8 +3,9 @@ const { createChangelogEntry, cleanPreviewTitle, previewAnchor, prependDatedEntr const fs = require('fs').promises const MockDate = require('mockdate') const readFileAsync = require('../../lib/readfile-async') -const expectedChangelogEntry = require('../fixtures/changelog-entry') -const expectedUpdatedChangelogFile = require('../fixtures/updated-changelog-file') +const readJsonFile = require('../../lib/read-json-file') +const expectedChangelogEntry = readJsonFile('./tests/fixtures/changelog-entry.json') +const expectedUpdatedChangelogFile = readJsonFile('./tests/fixtures/updated-changelog-file.json') describe('creating a changelog from old schema and new schema', () => { afterEach(() => { diff --git a/tests/helpers/schemas/feature-versions-schema.js b/tests/helpers/schemas/feature-versions-schema.js new file mode 100644 index 000000000000..15b0e662e847 --- /dev/null +++ b/tests/helpers/schemas/feature-versions-schema.js @@ -0,0 +1,18 @@ +const { schema } = require('../../../lib/frontmatter') + +// Copy the properties from the frontmatter schema. +const featureVersions = { + properties: { + versions: Object.assign({}, schema.properties.versions) + } +} + +// Remove the feature versions properties. +// We don't want to allow features within features! We just want pure versioning. +delete featureVersions.properties.versions.properties.feature + +// Call it invalid if any properties other than version properties are found. +featureVersions.additionalProperties = false +featureVersions.properties.versions.additionalProperties = false + +module.exports = featureVersions diff --git a/tests/linting/lint-files.js b/tests/linting/lint-files.js index 0b0497fe6f4b..c8ab4a743554 100644 --- a/tests/linting/lint-files.js +++ b/tests/linting/lint-files.js @@ -13,6 +13,7 @@ const { tags } = require('../../lib/liquid-tags/extended-markdown') const ghesReleaseNotesSchema = require('../helpers/schemas/ghes-release-notes-schema') const ghaeReleaseNotesSchema = require('../helpers/schemas/ghae-release-notes-schema') const learningTracksSchema = require('../helpers/schemas/learning-tracks-schema') +const featureVersionsSchema = require('../helpers/schemas/feature-versions-schema') const renderContent = require('../../lib/render-content') const getApplicableVersions = require('../../lib/get-applicable-versions') const { execSync } = require('child_process') @@ -31,6 +32,7 @@ const glossariesDir = path.join(rootDir, 'data/glossaries') const ghesReleaseNotesDir = path.join(rootDir, 'data/release-notes/enterprise-server') const ghaeReleaseNotesDir = path.join(rootDir, 'data/release-notes/github-ae') const learningTracks = path.join(rootDir, 'data/learning-tracks') +const featureVersionsDir = path.join(rootDir, 'data/features') const languageCodes = Object.keys(languages) @@ -186,7 +188,7 @@ const yamlWalkOptions = { } // different lint rules apply to different content types -let mdToLint, ymlToLint, ghesReleaseNotesToLint, ghaeReleaseNotesToLint, learningTracksToLint +let mdToLint, ymlToLint, ghesReleaseNotesToLint, ghaeReleaseNotesToLint, learningTracksToLint, featureVersionsToLint if (!process.env.TEST_TRANSLATION) { // compile lists of all the files we want to lint @@ -227,6 +229,11 @@ if (!process.env.TEST_TRANSLATION) { const learningTracksYamlAbsPaths = walk(learningTracks, yamlWalkOptions).sort() const learningTracksYamlRelPaths = learningTracksYamlAbsPaths.map(p => slash(path.relative(rootDir, p))) learningTracksToLint = zip(learningTracksYamlRelPaths, learningTracksYamlAbsPaths) + + // Feature versions + const featureVersionsYamlAbsPaths = walk(featureVersionsDir, yamlWalkOptions).sort() + const featureVersionsYamlRelPaths = featureVersionsYamlAbsPaths.map(p => slash(path.relative(rootDir, p))) + featureVersionsToLint = zip(featureVersionsYamlRelPaths, featureVersionsYamlAbsPaths) } else { // get all translated markdown or yaml files by comparing files changed to main branch const changedFilesRelPaths = execSync('git -c diff.renameLimit=10000 diff --name-only origin/main | egrep "^translations/.*/.+.(yml|md)$"', { maxBuffer: 1024 * 1024 * 100 }).toString().split('\n') @@ -236,7 +243,14 @@ if (!process.env.TEST_TRANSLATION) { console.log(`Found ${changedFilesRelPaths.length} translated files.`) - const { mdRelPaths = [], ymlRelPaths = [], ghesReleaseNotesRelPaths = [], ghaeReleaseNotesRelPaths = [], learningTracksRelPaths = [] } = groupBy(changedFilesRelPaths, (path) => { + const { + mdRelPaths = [], + ymlRelPaths = [], + ghesReleaseNotesRelPaths = [], + ghaeReleaseNotesRelPaths = [], + learningTracksRelPaths = [], + featureVersionsRelPaths = [], + } = groupBy(changedFilesRelPaths, (path) => { // separate the changed files to different groups if (path.endsWith('README.md')) { return 'throwAway' @@ -250,13 +264,29 @@ if (!process.env.TEST_TRANSLATION) { return 'ghaeReleaseNotesRelPaths' } else if (path.match(/\data\/learning-tracks/)) { return 'learningTracksRelPaths' + } else if (path.match(/\data\/features/)) { + return 'featureVersionsRelPaths' } else { // we aren't linting the rest return 'throwAway' } }) - const [mdTuples, ymlTuples, ghesReleaseNotesTuples, ghaeReleaseNotesTuples, learningTracksTuples] = [mdRelPaths, ymlRelPaths, ghesReleaseNotesRelPaths, ghaeReleaseNotesRelPaths, learningTracksRelPaths].map(relPaths => { + const [ + mdTuples, + ymlTuples, + ghesReleaseNotesTuples, + ghaeReleaseNotesTuples, + learningTracksTuples, + featureVersionsTuples + ] = [ + mdRelPaths, + ymlRelPaths, + ghesReleaseNotesRelPaths, + ghaeReleaseNotesRelPaths, + learningTracksRelPaths, + featureVersionsRelPaths + ].map(relPaths => { const absPaths = relPaths.map(p => path.join(rootDir, p)) return zip(relPaths, absPaths) }) @@ -266,6 +296,7 @@ if (!process.env.TEST_TRANSLATION) { ghesReleaseNotesToLint = ghesReleaseNotesTuples ghaeReleaseNotesToLint = ghaeReleaseNotesTuples learningTracksToLint = learningTracksTuples + featureVersionsToLint = featureVersionsTuples } function formatLinkError(message, links) { @@ -887,6 +918,38 @@ describe('lint learning tracks', () => { ) }) +describe('lint feature versions', () => { + if (featureVersionsToLint.length < 1) return + describe.each(featureVersionsToLint)( + '%s', + (yamlRelPath, yamlAbsPath) => { + let dictionary + + beforeAll(async () => { + const fileContents = await readFileAsync(yamlAbsPath, 'utf8') + dictionary = yaml.load(fileContents, { filename: yamlRelPath }) + }) + + it('matches the schema', () => { + const { errors } = revalidator.validate(dictionary, featureVersionsSchema) + + const errorMessage = errors.map(error => { + // Make this one message a little more readable than the error we get from revalidator + // when additionalProperties is set to false and an additional prop is found. + const errorToReport = error.message === 'must not exist' && error.actual.feature + ? `feature: '${error.actual.feature}'` + : JSON.stringify(error.actual, null, 2) + + return `- [${error.property}]: ${errorToReport}, ${error.message}` + }) + .join('\n') + + expect(errors.length, errorMessage).toBe(0) + }) + } + ) +}) + function validateVersion (version) { return versionShortNames.includes(version) || versionShortNameExceptions.some(exception => version.startsWith(exception)) diff --git a/tests/routing/developer-site-redirects.js b/tests/routing/developer-site-redirects.js index 53a776d34b02..89e75099c444 100644 --- a/tests/routing/developer-site-redirects.js +++ b/tests/routing/developer-site-redirects.js @@ -2,9 +2,10 @@ const path = require('path') const { eachOfLimit } = require('async') const enterpriseServerReleases = require('../../lib/enterprise-server-releases') const { get } = require('../helpers/supertest') -const restRedirectFixtures = require('../fixtures/rest-redirects') -const graphqlRedirectFixtures = require('../fixtures/graphql-redirects') -const developerRedirectFixtures = require('../fixtures/developer-redirects') +const readJsonFile = require('../../lib/read-json-file') +const restRedirectFixtures = readJsonFile('./tests/fixtures/rest-redirects.json') +const graphqlRedirectFixtures = readJsonFile('./tests/fixtures/graphql-redirects.json') +const developerRedirectFixtures = readJsonFile('./tests/fixtures/developer-redirects.json') const MAX_CONCURRENT_REQUESTS = 50 diff --git a/tests/routing/top-developer-site-path-redirects.js b/tests/routing/top-developer-site-path-redirects.js index 233b06ab5b8e..ee033f8cb631 100644 --- a/tests/routing/top-developer-site-path-redirects.js +++ b/tests/routing/top-developer-site-path-redirects.js @@ -1,7 +1,6 @@ -const fs = require('fs') -const path = require('path') const { head } = require('../helpers/supertest') -const topOldDeveloperSitePaths = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'tests/fixtures/top-old-developer-site-paths.json'))) +const readJsonFile = require('../../lib/read-json-file') +const topOldDeveloperSitePaths = readJsonFile('tests/fixtures/top-old-developer-site-paths.json') jest.useFakeTimers() diff --git a/tests/unit/feature-flags.js b/tests/unit/feature-flags.js index 4b41ada884eb..18be5f8207ce 100644 --- a/tests/unit/feature-flags.js +++ b/tests/unit/feature-flags.js @@ -1,5 +1,6 @@ require('../../lib/feature-flags') -const ffs = require('../../feature-flags') +const readJsonFile = require('../../lib/read-json-file') +const ffs = readJsonFile('./feature-flags.json') describe('feature flags', () => { Object.keys(ffs).forEach(featureName => { diff --git a/tests/unit/liquid.js b/tests/unit/liquid.js index 2ef7148489b4..c25a1b659ddd 100644 --- a/tests/unit/liquid.js +++ b/tests/unit/liquid.js @@ -1,7 +1,9 @@ const { liquid } = require('../../lib/render-content') -const middleware = require('../../middleware/contextualizers/short-versions') +const shortVersionsMiddleware = require('../../middleware/contextualizers/short-versions') +const featureVersionsMiddleware = require('../../middleware/contextualizers/features') const allVersions = require('../../lib/all-versions') const enterpriseServerReleases = require('../../lib/enterprise-server-releases') +const loadSiteData = require('../../lib/site-data') const template = ` {% if currentVersion ver_gt "enterprise-server@2.13" %}up to date{% endif %} @@ -25,6 +27,10 @@ const negativeVersionsTemplate = ` {% ifversion ghes != 3.1 %} I am not GHES 3.1 {% endif %} ` +const featureVersionsTemplate = ` + {% if placeholder %} I am placeholder content {% endif %} +` + describe('liquid template parser', () => { describe('custom operators', () => { describe('ver_gt', () => { @@ -68,7 +74,7 @@ describe('liquid template parser', () => { allVersions, enterpriseServerReleases } - await middleware(req, null, () => {}) + await shortVersionsMiddleware(req, null, () => {}) const output = await liquid.parseAndRender(shortVersionsTemplate, req.context) // We should have TWO results because we are supporting two shortcuts expect(output.replace(/\s\s+/g, ' ').trim()).toBe('I am FPT I am FTP or GHES < 3.0') @@ -81,7 +87,7 @@ describe('liquid template parser', () => { allVersions, enterpriseServerReleases } - await middleware(req, null, () => {}) + await shortVersionsMiddleware(req, null, () => {}) const output = await liquid.parseAndRender(shortVersionsTemplate, req.context) expect(output.trim()).toBe('I am GHAE') }) @@ -93,7 +99,7 @@ describe('liquid template parser', () => { allVersions, enterpriseServerReleases } - await middleware(req, null, () => {}) + await shortVersionsMiddleware(req, null, () => {}) const output = await liquid.parseAndRender(shortVersionsTemplate, req.context) expect(output.replace(/\s\s+/g, ' ').trim()).toBe('I am GHES I am GHES < 3.1 I am FTP or GHES < 3.0') }) @@ -105,7 +111,7 @@ describe('liquid template parser', () => { allVersions, enterpriseServerReleases } - await middleware(req, null, () => {}) + await shortVersionsMiddleware(req, null, () => {}) const output = await liquid.parseAndRender(shortVersionsTemplate, req.context) expect(output.replace(/\s\s+/g, ' ').trim()).toBe('I am GHES I am GHES < 3.1 I am 3.0 only') }) @@ -117,7 +123,7 @@ describe('liquid template parser', () => { allVersions, enterpriseServerReleases } - await middleware(req, null, () => {}) + await shortVersionsMiddleware(req, null, () => {}) const output = await liquid.parseAndRender(negativeVersionsTemplate, req.context) expect(output.replace(/\s\s+/g, ' ').trim()).toBe('I am not GHES I am not GHES 3.1') }) @@ -129,7 +135,7 @@ describe('liquid template parser', () => { allVersions, enterpriseServerReleases } - await middleware(req, null, () => {}) + await shortVersionsMiddleware(req, null, () => {}) const output = await liquid.parseAndRender(negativeVersionsTemplate, req.context) expect(output.replace(/\s\s+/g, ' ').trim()).toBe('I am not GHAE I am not GHES 3.1') }) @@ -141,9 +147,59 @@ describe('liquid template parser', () => { allVersions, enterpriseServerReleases } - await middleware(req, null, () => {}) + await shortVersionsMiddleware(req, null, () => {}) const output = await liquid.parseAndRender(negativeVersionsTemplate, req.context) expect(output.replace(/\s\s+/g, ' ').trim()).toBe('I am not GHAE') }) }) + + describe('feature versions', () => { + // Create a fake req so we can test the feature versions middleware + const req = { language: 'en', query: {} } + + let siteData + beforeAll(async () => { + const allSiteData = await loadSiteData() + siteData = allSiteData.en.site + }) + + test('does not render in FPT because feature is not available in FPT', async () => { + req.context = { + currentVersion: 'free-pro-team@latest', + page: {}, + allVersions, + enterpriseServerReleases, + site: siteData + } + await featureVersionsMiddleware(req, null, () => {}) + const outputFpt = await liquid.parseAndRender(featureVersionsTemplate, req.context) + expect(outputFpt.includes('placeholder content')).toBe(false) + }) + + test('renders in GHES because feature is available in GHES', async () => { + req.context = { + currentVersion: `enterprise-server@${enterpriseServerReleases.latest}`, + page: {}, + allVersions, + enterpriseServerReleases, + site: siteData + } + await featureVersionsMiddleware(req, null, () => {}) + const outputFpt = await liquid.parseAndRender(featureVersionsTemplate, req.context) + expect(outputFpt.includes('placeholder content')).toBe(true) + }) + + test('renders in GHAE because feature is available in GHAE', async () => { + req.context = { + currentVersion: 'github-ae@latest', + page: {}, + allVersions, + enterpriseServerReleases, + site: siteData + } + await featureVersionsMiddleware(req, null, () => {}) + const outputFpt = await liquid.parseAndRender(featureVersionsTemplate, req.context) + expect(outputFpt.includes('placeholder content')).toBe(true) + }) + }) }) diff --git a/tests/unit/page.js b/tests/unit/page.js index bdb23cdee864..2711fb932ab9 100644 --- a/tests/unit/page.js +++ b/tests/unit/page.js @@ -1,7 +1,8 @@ const path = require('path') const cheerio = require('cheerio') const Page = require('../../lib/page') -const prerenderedObjects = require('../../lib/graphql/static/prerendered-objects') +const readJsonFile = require('../../lib/read-json-file') +const prerenderedObjects = readJsonFile('./lib/graphql/static/prerendered-objects.json') const allVersions = require('../../lib/all-versions') const enterpriseServerReleases = require('../../lib/enterprise-server-releases') const nonEnterpriseDefaultVersion = require('../../lib/non-enterprise-default-version') @@ -534,6 +535,48 @@ describe('Page class', () => { expect(nonEnterpriseDefaultPlan in page.versions).toBe(false) expect(page.versions['enterprise-server']).toBe('*') }) + + test('feature versions frontmatter', async () => { + // This fixture file has the frontmatter: + // + // versions: + // fpt: '*' + // ghes: '*' + // feature: 'placeholder' + // + // and placeholder.yml has: + // + // versions: + // ghes: '<2.22' + // ghae: '*' + // + // So we expect to get the versioning from both. + const page = await Page.init({ + relativePath: 'feature-versions-frontmatter.md', + basePath: path.join(__dirname, '../fixtures'), + languageCode: 'en' + }) + + // Test the raw page data. + expect(page.versions.fpt).toBe('*') + expect(page.versions.ghes).toBe('>2.21') + expect(page.versions.ghae).toBeUndefined() + // The `feature` prop gets deleted by lib/get-applicable-versions, so it's undefined. + expect(page.versions.feature).toBeUndefined() + + // Test the resolved versioning, where GHES releases specified in frontmatter and in + // feature versions are combined (i.e., one doesn't overwrite the other). + // We can't test that GHES 2.21 is _not_ included here (which it shouldn't be), + // because lib/get-applicable-versions only returns currently supported versions, + // so as soon as 2.21 is deprecated, a test for that _not_ to exist will not be meaningful. + // But by testing that the _latest_ GHES version is returned, we can ensure that the + // the frontmatter GHES `*` is not being overwritten by the placeholder's GHES `<2.22`. + expect(page.applicableVersions.includes('free-pro-team@latest')).toBe(true) + expect(page.applicableVersions.includes(`enterprise-server@${latest}`)).toBe(true) + expect(page.applicableVersions.includes('github-ae@latest')).toBe(true) + expect(page.applicableVersions.includes('feature')).toBe(false) + expect(page.applicableVersions.includes('placeholder')).toBe(false) + }) }) describe('platform specific content', () => {